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.💻😅
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.
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'
}
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()
}
🚀🚀🚀
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`
}
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
}
}
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")
}
}
}
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)
}
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"
}
}
}
}
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
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)