DEV Community

Cover image for Planing Multi-Module Android Project πŸ“
Neeraj Sharma
Neeraj Sharma

Posted on

Planing Multi-Module Android Project πŸ“

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.

empty-levels

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.

stacked-module-hierarchy

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 {
    ...
}

Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 {
    ... 
} 
Enter fullscreen mode Exit fullscreen mode

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)
    ...
} 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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"))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
]
Enter fullscreen mode Exit fullscreen mode

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())
}

Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()) 
}
Enter fullscreen mode Exit fullscreen mode

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"))
} 

Enter fullscreen mode Exit fullscreen mode

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:

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.

Buy Me A Coffee

Your coffee powers:

  • πŸ“ More detailed technical guides
  • πŸŽ₯ Tutorial videos
  • πŸ’‘ Code examples and templates

Sentry mobile image

Is your mobile app slow? Improve performance with these key strategies.

Improve performance with key strategies like TTID/TTFD & app start analysis.

Read the blog post

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

πŸ‘‹ Kindness is contagious

Please leave a ❀️ or a friendly comment on this post if you found it helpful!

Okay