Object Oriented Approach to Organizing Gradle Dependencies in Android Projects

Introduction

In multi-module Android applications there is a problem with gradle dependency organization. Each dependency is specified separately. Something like this

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")

    implementation("androidx.activity:activity-compose:1.9.1")
    implementation(platform("androidx.compose:compose-bom:2024.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.navigation:navigation-compose:2.8.0")
    debugImplementation("androidx.compose.ui:ui-tooling")

    implementation("com.google.dagger:hilt-android:2.51.1")
    kapt("com.google.dagger:hilt-android-compiler:2.51.1")
    kapt("androidx.hilt:hilt-compiler:1.2.0")

    implementation(project(":mymodule"))
    
    ...

  }

Of course, in real projects there can be many times more dependencies. In this example, I just want to demonstrate the Object-Oriented Approach to organizing dependencies.
The problem is that such code has to be duplicated from module to module. Of course, not forgetting to check, since dependencies can be different in each module.

Dependencies may conflict with each other or different versions may be used. Which is obviously not good.

Of course, there are solutions that make writing this kind of code a little easier.
This is a description of dependencies in a toml file or moving dependencies into global variables using Groovy or Kotlin Dsl.
After applying these approaches, the code will look like this

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.androidx.lifecycle.runtime.ktx)

    implementation(libs.composeActivity)
    implementation(libs.composeBom)
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    implementation(libs.composeNavigation)
    debugImplementation(libs.androidx.ui.tooling)

    implementation(libs.hilt.android)
    kapt(libs.hilt.android.compiler)
    kapt(libs.androidx.hilt.compiler)

    implementation(project(":mymodule"))

    ...
  
  }

But, in my opinion, such a solution does not solve the problem of procedural organization of dependencies.

Has it gotten better? The answer is no. Yes, we have solved the conflict problem. And now dependencies are moved to global variables. But this has not solved the problem of code duplication. And our code is still written in a procedural style. We connect dependencies one by one.
Plus, each module gets absolute freedom in connecting dependencies.
Let's limit it a little.

The example will be shown using Kotlin Dsl, but this is not essential. A similar result can be achieved using Groovy gradle.

Let's add an extension to the Kotlin Dsl module

import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.dsl.DependencyHandler

fun DependencyHandler.implementation(dependency: String) {
    add("implementation", dependency)
}

fun DependencyHandler.implementation(dependency: Dependency) {
    add("implementation", dependency)
}

fun DependencyHandler.kapt(dependency: String) {
    add("kapt", dependency)
}

fun DependencyHandler.testImplementation(dependency: String) {
    add("testImplementation", dependency)
}

fun DependencyHandler.androidTestImplementation(dependency: String) {
    add("androidTestImplementation", dependency)
}

fun DependencyHandler.androidTestImplementation(dependency: Dependency) {
    add("androidTestImplementation", dependency)
}

fun DependencyHandler.debugImplementation(dependency: String) {
    add("debugImplementation", dependency)
}

The extension list may not be complete. But you can easily add to it or change it.

Now let's create dependency objects

object Android {
    operator fun DependencyHandler.invoke() {
        implementation(AppDependencies.Android.androidxCooreKtx)
        implementation(AppDependencies.Android.androidxAppcompat)
        implementation(AppDependencies.Android.androidxLifecycleRuntimeKtx)
    }
}

object Compose {
    operator fun DependencyHandler.invoke() {
        implementation(AppDependencies.Compose.composeActivity)
        implementation(platform(AppDependencies.Compose.composeBom))
        implementation(AppDependencies.Compose.composeUi)
        implementation(AppDependencies.Compose.composeUiGraphics)
        implementation(AppDependencies.Compose.composeUiToolingPreview)
        implementation(AppDependencies.Compose.composeMaterial3)
        implementation(AppDependencies.Compose.composeNavigation)
        debugImplementation(AppDependencies.Compose.composeUiTooling)
    }
}

object Hilt {
    operator fun DependencyHandler.invoke() {
        implementation(AppDependencies.Hilt.hiltAndroid)
        kapt(AppDependencies.Hilt.androidxHiltCompiler)
        kapt(AppDependencies.Hilt.hiltAndroidCompiler)
    }
}

object AppDependencies {
    object Android {
        private const val coreKtx = "1.13.1"
        private const val appCompat = "1.7.0"
        private const val lifecycleRuntimeKtx = "2.8.4"

        const val androidxCooreKtx = "androidx.core:core-ktx:${coreKtx}"
        const val androidxAppcompat = "androidx.appcompat:appcompat:${appCompat}"
        const val androidxLifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${lifecycleRuntimeKtx}"
    }

    object Hilt {
        private const val hilt = "2.51.1"
        private const val hiltAndroidX = "1.2.0"

        const val hiltAndroid = "com.google.dagger:hilt-android:${hilt}"
        const val hiltAndroidCompiler = "com.google.dagger:hilt-android-compiler:${hilt}"
        const val androidxHiltCompiler = "androidx.hilt:hilt-compiler:${hiltAndroidX}"
    }

    object Compose {
        private const val composeBomVersion = "2024.08.00"
        private const val activityComposeVersion = "1.9.1"
        private const val composeNavigationVersion = "2.8.0"

        const val composeMaterial3 = "androidx.compose.material3:material3"
        const val composeUi = "androidx.compose.ui:ui"
        const val composeUiGraphics = "androidx.compose.ui:ui-graphics"
        const val composeUiTooling = "androidx.compose.ui:ui-tooling"
        const val composeUiToolingPreview = "androidx.compose.ui:ui-tooling-preview"
        const val composeBom = "androidx.compose:compose-bom:${composeBomVersion}"
        const val composeActivity = "androidx.activity:activity-compose:${activityComposeVersion}"
        const val composeNavigation = "androidx.navigation:navigation-compose:${composeNavigationVersion}"
    }

}

As a result, the gradle file now looks like this

dependencies {
    Android()
    Compose()
    Hilt()
    Project(":mymodule")
}

Conclusion

This approach allows you to combine dependencies to suit the needs of the project and has a number of advantages:

  • it is possible to manage dependencies (implementation, kapt, androidTestImplementation, etc.)

  • reduces the amount of code

  • dependency logic is encapsulated in objects

  • possibility of reuse

  • modules connect dependencies only those dependencies that relate to the subject area (of course, if you prohibit connecting dependencies directly)

  • declarative approach

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *