DEV Community

Cover image for Multi-Modules Architecture: Dependency Management _ Build your own gradle plugin
Emma Ham
Emma Ham

Posted on

Multi-Modules Architecture: Dependency Management _ Build your own gradle plugin

Hey fellow devs!

Today, I've come here to talk about my frustrations that I've got to develop over the past few years in working in multi-module projects with
Dependency management

And how I managed to make them a bit less, well, head-scratching.💻😅

android


1: Understanding Multi-Module Projects

So, what's a multi-module project? As your codebase grows, it becomes harder to manage, resembling a monolithic structure. Multi-modularization comes to the rescue, making your app more modular, scalable, and maintainable.

The rise of on-demand delivery apps has fueled the popularity of modular codebases, solving common development problems. In this type of project, you'll see multiple separate modules, such as UI, data, and feature modules like login, signup, and dashboard.

multi-module example


2: The Dependency Management Dilemma

The tricky part in multi-modules? Dependency management.
As much as I loved the idea of app modularisation, I've realised it is not always sunshines and rainbows when it comes to managing dependencies.

With 10 modules, you end up with 12+ build.gradle files, and they all look eerily similar. Take a look at this example of a typical /build.gradle:

(Alert! This might be a bit of eye sore, Lol)

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'
    id 'org.jlleitschuh.gradle.ktlint'
}

android {
    compileSdk 33
    defaultConfig {
        applicationId 'com.example.project.app'
        minSdk 26
        targetSdk 33
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    namespace 'com.example.project.app'

    compileOptions {
        sourceCompatibility = 17
        targetCompatibility = 17
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
    packagingOptions {
        resources.excludes.add("META-INF/*")
    }
}

repositories {
    mavenCentral()
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.core:core-ktx:1.9.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.8.0'
    implementation 'com.jakewharton.timber:timber:5.0.1'

    // Compose dependencies
    implementation "androidx.compose.material:material:1.4.3"
    implementation "androidx.compose.ui:ui:1.4.3"
    implementation "androidx.compose.ui:ui-tooling-preview:1.4.3"
    testImplementation 'junit:junit:4.12'
    debugImplementation "androidx.compose.ui:ui-tooling:1.4.3"
    implementation 'androidx.activity:activity-compose:1.7.0'
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
    implementation "androidx.navigation:navigation-compose:2.5.3"
    implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'

    // DI dependencies - Dagger Hilt
    implementation "com.google.dagger:hilt-android:2.44.2"
    kapt "com.google.dagger:hilt-android-compiler:2.44.2"
    kapt "androidx.hilt:hilt-compiler:1.0.0"

    // Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1'

    // Local unit tests
    testImplementation "androidx.test:core:1.4.0"
    testImplementation "junit:junit:4.13.2"
    testImplementation "androidx.arch.core:core-testing:2.1.0"
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1"
    testImplementation "com.google.truth:truth:1.1.3"
    testImplementation 'app.cash.turbine:turbine:0.12.1'
    testImplementation "io.mockk:mockk:1.13.8"
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.1.0-alpha04"

    // Instrumentation tests
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.4.3"
    androidTestImplementation 'com.google.dagger:hilt-android-testing:2.37'
    kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.37'
    androidTestImplementation "junit:junit:4.13.2"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1"
    androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
    androidTestImplementation "com.google.truth:truth:1.1.3"
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test:core-ktx:1.4.0'
    androidTestImplementation "io.mockk:mockk-android:1.13.8"
    androidTestImplementation 'androidx.test:runner:1.5.2'
}
Enter fullscreen mode Exit fullscreen mode

I see two major problems here.

First, all dependency versions are hardcoded.
Updating a library means updating multiple build.gradle files, a developer's nightmare.
(Typically, resolve this with versions object which I can talk about in the next post if anyone is interested.)

Second, there's a ton of duplicated code
plugin IDs, Android configuration, compile options, and more.

In this post, I want to talk about the second problem above and how to eliminate the duplications and make the build gradles more organised and cleaner.


3: What can we expect from this post?

Spoiler Alert!!
I will show you what your build.gradle file would look like after following this approach.
Remember, the eye-sore example above?
Well, here is an updated version.

apply<ProjectGradlePlugin>()
plugins {
    `android-library`
    `kotlin-android`
}
android {
    namespace = "com.example.project.module"
}
dependencies {
    // After resolving first problem, let me know anyone 
    wants to read about how to update this dependencies 
    block.
    // Modules
    design()
    navigation()
    core_data()
    model()
    datastore()

    // Dependencies
    material()
    compose()
    googleSupportLibraries()
    networking()
    data()
}

Enter fullscreen mode Exit fullscreen mode

🚀🚀🚀
That's right, this is it!

All those duplicated code is gone in this build.gradle file and now it is obviously much shorter and cleaner.

How do we do this? Stay tuned in this post!

Let's journey through the solution.


4: Solution: Build your own custom gradle plugin

In this chapter, we'll explore a practical solution for eliminating duplicated code in your multi-module Android project by creating your own custom Gradle plugin.

Let's break it down into steps:

Step 1: Create the buildSrc/ Module

If you haven't already set up the buildSrc/ module in your project, now's the time to do it. You can create this directory at the root of your project by following these simple steps.
This is also a great opportunity to migrate from Groovy to Kotlin DSL for your build.gradle files. If you decide to do so, don't forget to add the Kotlin DSL plugin in the build.gradle.kts file in buildSrc/.

plugins {
    `kotlin-dsl`
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Develop Your Custom Plugin

Inside the buildSrc/src/ directory, create a Kotlin class that extends Plugin. You can name it whatever you prefer; for this post, let's call it "ProjectGradlePlugin." You'll need to override the apply function within this class.

class ProjectGradlePlugin : Plugin<Project> {
    override fun apply(project: Project) {
     // Empty for now
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Extracting Duplicate Code from build.gradle Files

a. Handling Plugins

You often find duplicated plugin declarations in your build.gradle files.
To simplify, we'll extract these plugin declarations into your custom plugin class. However, you should leave Android library plugins in place, as they are required to create the Android block.

class ProjectGradlePlugin : Plugin<Project> {
    override fun apply(project: Project) {
         // Applying plugins
         project.apply {
            plugin("kotlin-kapt")
            plugin("dagger.hilt.android.plugin")
            plugin("org.jlleitschuh.gradle.ktlint")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We can now remove those three lines from the build.gradle file.

b. Handling Android Block Configuration

The Android block in build.gradle files often contains a significant amount of duplicated code, such as SDK versions and build features. To streamline this, create an extension function for the Android block in your custom plugin class. This extension function returns the LibraryExtension Gradle class, allowing you to modify the Android configuration within your custom plugin.

    private fun Project.android(): LibraryExtension {
        return extensions.getByType(LibraryExtension::class.java)
    }
Enter fullscreen mode Exit fullscreen mode

Then we call this function inside of our apply() function.

class ProjectGradlePlugin : Plugin<Project> {
    override fun apply(project: Project) {
         // Applying plugins
         // Applying Configuration
         project.android().apply { 
            compileSdk = 33

            defaultConfig {
                minSdk = 21
                targetSdk = 33
                testInstrumentationRunner = ProjectConfig.testInstrumentationRunner
                consumerProguardFile(ProjectConfig.proguardFile)
            }
            buildFeatures {
                compose = true
            }
            compileOptions {
                sourceCompatibility = JavaVersion.VERSION_17
                targetCompatibility = JavaVersion.VERSION_17
            }
            composeOptions {
                kotlinCompilerExtensionVersion = "1.4.3"
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we can remove the same codes inside of andorid {} in the original build.gradle file.

c. Adding More Extensions

You can continue to extract and simplify further code blocks in a similar manner, such as specifying the Java version in the Kotlin block in your custom plugin.


Step 4: Applying Your Custom Plugin

Once you've extracted all the necessary code into your custom plugin, applying it is straightforward. Just add a single line to your build.gradle.kts file:

apply<ProjectGradlePlugin>()


5: Summary

Finish
That's it! Your Gradle files are now cleaner and more organized. When you need to update configurations for your modules, you can simply make the updates within your custom project plugin. It's that easy!

We've covered a lot of ground, but this approach can significantly simplify your multi-module Android project.

But wait, the adventure continues! If you've got questions or awesome ideas swirling in your developer brain, don't hesitate. Share them in the comments below. Your solutions and suggestions are the secret sauce to our coding success! 🚀😄


Useful resource
Here are useful articles that you might find helpful in case you are stuck with the points above.
Multi Module Architecture
BuildSrc and Kotlin DSL
Multi module architecture and BuildSrc
Public github project with custom gradle plugin implementation by Philipp Lackner

Top comments (0)