Introduction
Hello, fellow developer! π Are you ready to take your Android projects to the next level? In this guide, we'll dive into the exciting world of multi-module projects in Android Studio, using Gradle and Kotlin. Multi-module projects are a fantastic way to keep your codebase organized and turbocharge your build times. By breaking your project into smaller, independent modules, you can build and test them more efficiently.
Join me on this journey as we explore how to plan your project structure, create modules, and configure dependencies between them. Let's get started and make your development process smoother and more enjoyable!
Before We Begin π
This guide well help you:
- β Understand why multi-module architecture is matters
- β Learn how to plan before Modularizing your app
- β How to use conventional plugin to reduce boilerplate gradle configiuration
- β Master dependency management between modules
- β Speed up your build times significantly
Why Multi-Module?
Picture this: You are working on an exciting android project everything in one neat module (just like I did when starting out).
Life seems simple, right? But then comes the testing phase..
The single module chanllenge π―
Let me share a real scenario that might sound familiar:
- You write a brilliant piece of code π»
- you create a test for it π§ͺ
- you hit the compile button.. and wait.. and wait.. β
- The entire project needs to compile just to test that one small component π .
- Oh, and every tiny issue anywhere in the project needs to be fixed before you can run that test!.
The Multi-Module Approach π
Breaking down your project into modules is like organizing a well-structured library. Each module serves a specific purpose and works together harmoniously, making your codebase cleaner, more maintainable, and easier to test.
Now again come back to the real world scenario
- You write a brilliant piece of code π»
- you create a test for it π§ͺ
- you hit the compile button.. and you just wait for the module to build, that contain your test target.
- Now you don't need to care of every tiny issue in the other modules.
Common Reasons To Embracing The Multi-Module Architecture
Following are the common reasons to use multi-module projects:
-
Save build time
: Gradle only builds the modules that have changed, which can significantly reduce build times. -
Better code organization
: Modules help you organize your codebase into logical units, making it easier to understand and maintain. -
Separation of concerns
: Each module can have its own responsibilities and dependencies, making it easier to test and maintain. -
Reusability
: Modules can be reused across different projects, reducing duplication and improving consistency. -
Modular testing
: Each module can be tested independently, making it easier to identify and fix issues. -
Scalability
: As your project grows, multi-module projects can help you scale your codebase more efficiently. -
Collaboration
: Multi-module projects can facilitate collaboration among developers by allowing them to work on different modules simultaneously.
Planning Your Project Structure
We must plain our project structure depending on the features and requirements of the application.
This is what where most of the beginner android developers struggle. Even if they are aware of the benefits of multi-module projects, they don't know how to plan their project structure. If you are one of them, Don't worry, I will help you with that. Trust me you are not alone I was also in the same situation too.
Consider a Note taking project with the following features:
- Authentication: Users can sign up and log in to the app.
- Notes: Users can create, edit, and delete notes.
- Search: Users can search for notes by title or content.
- Settings: Users can customize app settings.
- Notifications: Users can receive notifications for new notes.
- Backup and Restore: Users can backup and restore their notes.
Find Reasonable Parameters To Divide Your Project
You must think of reasonable parameters to divide the this example note taking app into modules.
The first parameter we can see is the feature. we can divide the project
by it's feature. For each feature, we would have a module. And an app module which use these feature modules and create a final app.
Furthermore, we can divide the each feature into modules based on the
Architecture layer.
- Ui layer
- Domain layer / Business Logic layer
- Data layer
- API layer
You can find valid reasons to divide your real project just give some time to think.
Initially thinking like this will give you a view of the Module Hierarchy
from top to down.
But a better way to think is to start from the bottom and work your way up.
You would definately need to build basic components like data repository
and auth-apis
before you can start building the Final working features like NoteEditor
, LoginPage
etc. So, you would need to start with the bottom layer and work your way up.
Thinking in both directions would help you get to a better decision.
In the next section, I will show you how to plan your project structure by visualizing the process
and forcing some dependency rules between modules.
Visualizing The Process Of Modularization
To make thought process easier, I am visualizing the process of modularization in the following diagram. By Stacking the modules in levels of dependency.
In this approach, I've created a stack of empty levels, where each level represents a position in the hierarchy. The key rule here is: each module at a lower level is open to use by modules above it, but not vice versa. This simple rule prevents cyclic dependencies between modules, which is a common issue in multi-module projects.
First we divided the project by features then we divided the features into different layers.
Now we can start filling the empty levels with modules depeding on the dependency among them.
Following is the final structure of the example note taking project.
API
: The API module is responsible for making network requests and handling responses. If it becomes too complex, you can split it further into multiple modules as auth-api, note-api, etc. In the diagram, It sits on the bottom level and is available to all the modules above it. It will contain classes for data-transfer objects (DTOs) and network apis.
for example, It can have a NoteApi
that makes network requests to the server and returns a list of notes.
interface NoteApi {
suspend fun getNotes(): List<Note>
suspend fun getNote(id: String): Note
// save user note on server
suspend fun uploadNotes(notes: List<Note>): Boolean
}
class NoteApiImpl(
private val sessionToken: String,
private val apiUrl: String,
) : NoteApi {
...
}
Data
: The data module is responsible for storing and retrieving data.
It can have classes for data models (like Note, User, etc.) and local data storage.
for example, It can have a NoteDao
that stores and retrieves notes from a local database.
interface NoteDao {
@Query("SELECT * FROM notes")
suspend fun getNotes(): List<Note>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getNote(id: String): Note
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNote(note: Note)
@Update
suspend fun updateNote(note: Note)
@Delete
suspend fun deleteNote(note: Note)
}
Domain
: The domain module is responsible for business logic.
It defines interfaces and classes for the business logic.
You could put UI-data models (like Note, User, etc.) in this module.
It is also responsible for mapping of data entities and network responses to the Ui data models.
For example, It provides a NoteService
that provides methods for getting and saving notes locallly and remotely. It acts as a single source of truth for the notes data. It automatically syncs the local and remote data.
interface NoteService: NoteApi {
fun getNotes(): Flow<List<Note>>
fun getNote(id: String): Flow<Note>
suspend fun insertNote(note: Note)
suspend fun updateNote(note: Note)
}
class NoteServiceImpl(
private val mapper: Mapper,
private val localNoteDao: NoteDao,
private val remoteNoteApi: NoteApi,
private val sessionToken: String,
) : NoteService {
...
}
Core Features
: This module contain the common components that are used by multiple features. For example, a common Ui component, a common business logic, etc.
You can also create UIKit Module on this level which contains common UI components like NotesCard, NoteEditor, CustomButtons, etc.
Features
: Features modules are top-most modules in the hierarchy.
They are responsible for handling the user interactions and drawing the UI.
They can selectively use the modules from the below levels.
APP Module
: The app module is the entry point of the application. It contains the main activity and combines all the features.
In this similar way, you can create a multi-module project for your Android application.
Conventional Plugin
Configuration of the Multiple Android Libraries is a very tedious task. Because each library is going to have an
android dsl block and some set of plugins like following.
plugins {
alias(libs.plugins.android.library)
alias(libs.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.example.mylibrary"
compileSdk = 33
defaultConfig {
minSdk = 24
targetSdk = 33
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
implementation(libs.kotlinx.serialization.json)
...
}
If you are not familiar with the version catalogs file (libs.versions.toml), you can refer to the official documentation here.
By using the conventional plugin, we could have common config logic which can reduce the boilerplate code and make the configuration of the libraries easier.
In the example NoteApp, we have multiple features like auth, notes, note-editor, etc. Let's say each feature is a android library, uses jetpack compose framework,
Jetpack Navigation, and uses kotlin serialization. This is what we found common
in all the features.
So, we can create a feature-lib
plugin which can be applied to all the features and applies the common configuration to each sub-project.
Gradle buildSrc
Before writing the convention plugin, we need to be clear about the buildSrc
directory in a gradle project.
The buildSrc
directory in a Gradle project is a special directory that is used to build and maintain custom build logic, plugins, and dependencies that are used in the main project build. Gradle automatically compiles and includes the code in the buildSrc directory in the build script classpath, making it available to the main build scripts.
Create a new directory buildSrc
in the root of your project.
my-project/
βββ build.gradle
βββ settings.gradle
βββ buildSrc/ <-- Create this directory
Create a new Kotlin file build.gradle.kts
in the buildSrc
directory.
buildSrc is also a gradle project. So, we can create a build.gradle.kts
file in the buildSrc
directory.
plugins {
`kotlin-dsl`
}
dependencies {
implementation(libs.kotlin.gradle.plugin)
implementation(libs.android.gradle.plugin)
}
Also create a settings.gradle.kts
file in the buildSrc
directory.
import java.net.URI
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
mavenLocal()
maven { url = URI("https://jitpack.io") }
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
Project Version Catalog
Define the following in the root-project/gradle/libs.versions.toml
file.
[versions]
agp = "8.0.2" # android gradle plugin
kotlin = "2.0.21"
kotlinxSerializationJson = "1.7.3"
composeBom = "2024.12.01"
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
[libraries]
kotlinxSerializationJson = {
group = "org.jetbrains.kotlinx",
name = "kotlinx-serialization-json",
version.ref = "kotlinxSerializationJson" }
}
# Android Gradle Plugin as a dependency to buildSrc module.
agp = {
group = "com.android.tools.build",
name = "gradle",
version.ref = "agp"
}
# Kotlin Gradle Plugin as a dependency to buildSrc module.
kotlinAGP = {
group = "org.jetbrains.kotlin.android",
name = "org.jetbrains.kotlin.android.gradle.plugin",
version.ref = "kotlin"
}
desugar_jdk_libs = {
module = "com.android.tools:desugar_jdk_libs",
version.ref = "desugar_jdk_libs"
}
# Compose dependencies
androidx-composeActivity = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
androidx-lifecyle = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifeCycle" }
androidx-lifetimeRuntimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifeCycle" }
androidx-composeBom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-composeUi = { module = "androidx.compose.ui:ui" }
androidx-composeFoundation = { module = "androidx.compose.foundation:foundation" }
androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx_material3" }
androidx-composeUiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-composeUiTooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-uiGraphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" }
androidx-nav-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.8.5" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" }
androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" }
[bundles]
compose-core = [
"androidx-composeActivity",
"androidx-composeBom",
"androidx-composeUi",
"androidx-composeFoundation",
"androidx-material3",
"androidx-compose-uiGraphics",
"androidx-constraintlayout",
"androidx-nav-compose",
"androidx-paging-compose",
"androidx-ui-text-google-fonts",
"material-icons-core",
"material-icons-extended",
]
compose-tooling = [
"androidx-composeUiTooling",
"androidx-composeUiToolingPreview"
]
Feature-Lib Plugin
Now it's time to create our feature-lib
plugin. Create a new file feature-lib.gradle.kts
in the buildSrc/src/main/kotlin
directory.
Note: The
feature-lib.gradle.kts
file is called a Precompiled Gradle Script Plugin, Know more here.
import com.example.*
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
// following plugins are applied to each project which uses this convention plugin
apply {
plugin("android-lib")
plugin(libs.findPlugin("compose-compiler").get().get().pluginId)
plugin(libs.findPlugin("kotlinx-serialization").get().get().pluginId)
}
dependencies {
implementation(libs.findBundle("compose-core").get())
debugImplementation(libs.findBundle("compose-tooling").get())
}
Note that Code in the buildSrc/src
can not use gradle dsl for version catalogs and dependency functions like implementation, api, testImplementation, etc.
The above code snippet clearly shows how to use version catalog in plugin the script.
And It imports the extensions for implementation, debugImplementation from the com.example package,
Also note that the above plugin script applies the another plugin convention plugin android-lib
which I will create later.
DependencyHandler Extensions
The following are the DependencyHandler extensions that are used in the plugin-script
dependencies block. file: buildSrc/src/main/kotlin/com/example/DependenciesExt.kt
package com.example
import org.gradle.api.artifacts.dsl.DependencyHandler
fun DependencyHandler.implementation(dependencyNotation: Any) {
add("implementation", dependencyNotation)
}
// debug implementation
fun DependencyHandler.debugImplementation(dependencyNotation: Any) {
add("debugImplementation", dependencyNotation)
}
fun DependencyHandler.api(dependencyNotation: Any) {
add("api", dependencyNotation)
}
fun DependencyHandler.compileOnly(dependencyNotation: Any) {
add("compileOnly", dependencyNotation)
}
fun DependencyHandler.runtimeOnly(dependencyNotation: Any) {
add("runtimeOnly", dependencyNotation)
}
fun DependencyHandler.testImplementation(dependencyNotation: Any) {
add("testImplementation", dependencyNotation)
}
fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) {
add("androidTestImplementation", dependencyNotation)
}
fun DependencyHandler.kapt(dependencyNotation: Any) {
add("kapt", dependencyNotation)
}
android-lib Plugin
Now it's time to create our android-lib
plugin. Create a new file android-lib.gradle.kts
in the buildSrc/src/main/kotlin/
directory.
import com.example.*
import com.android.build.gradle.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
// following plugins are applied to each project which uses this convention plugin
apply {
plugin("com.android.library")
plugin("org.jetbrains.kotlin.android")
}
// same as android block in gradle file
configure<LibraryExtension> {
namespace = "com.example"
compileSdk = 34
defaultConfig {
minSdk = 24
lint.targetSdk = 34
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
coreLibraryDesugaring(libs.findLibrary("desugar_jdk_libs").get())
}
All the above code is pretty straightforward. We are applying the com.android.library
and org.jetbrains.kotlin.android
plugins. And we are configuring the android LibraryExtension. If you wish to create an android-app
plugin, then you can use the com.android.application
plugin instead of com.android.library
. And you can configure the ApplicationExtension
instead of LibraryExtension in the build.gradle.kts
file.
Now we have common configurations for all the android libraries modules in our project.
According to your project requirements, you can create more plugins following the same pattern.
Using the Convention Plugin
To use the convention plugin, we need to apply the plugin in the build.gradle.kts
file of the project.
plugins {
id("feature-lib")
}
// you can optionally override the default
// config applied by the feature-lib plugin
android {
namespace = "com.example.notes"
}
dependencies {
// you don't need to rewrite compose dependencies here
// because the feature-lib plugin already applied the compose dependencies
// only add the additional dependencies like
implementation(libs.coil.compose)
// add the dependencies to other modules like
implementation(project(":auth"))
implementation(project(":notes"))
}
Share Your Thoughts! π
Did this guide help you structure your Android project better? I'd love to hear your thoughts!
- Drop a comment below about your experience
- Share your own modularization tips and tricks
- Let me know which topics you'd like me to cover next
- Connect with other developers in our Telegram community
Your feedback helps make these guides even more valuable for the developer community. Let's learn and grow together! π
Follow me on:
- Telegram Channel for more Android development tips
- GitHub for code examples and projects
This is it. I hope you find this guide helpful. Thanks for reading.
Give a feedback if you have any questions or suggestions.
Support My Work β
If you found this guide helpful and want to fuel more developer-focused content, consider buying me a coffee! Your support helps me create more in-depth tutorials and resources for the developer community.
Your coffee powers:
- π More detailed technical guides
- π₯ Tutorial videos
- π‘ Code examples and templates
Top comments (0)