Scalable Android Multi-Module Architecture

As mobile applications scale, the single-module "monolithic" approach inevitably hits a performance wall. Gradle build times exceed acceptable thresholds (often 10+ minutes for a clean build), and code coupling makes parallel development risky. In a team of 20+ engineers, a monolithic structure results in frequent merge conflicts in the `AppModule` and a fragile codebase where a change in a UI component can inadvertently break a backend service logic. This article does not discuss basic setup but focuses on the architectural strategy of modularization to decouple logic, enforce boundaries, and accelerate incremental builds.

1. Strategic Decomposition: Layer vs. Feature

The primary decision in modularization is how to slice the application. Early attempts often focus on Layer-based Modularization (e.g., `:data`, `:domain`, `:presentation`). While this enforces Clean Architecture, it does not solve the build time issue effectively because a change in the `:data` layer triggers a recompilation of everything depending on it—essentially the entire app.

For scalable engineering, Feature-based Modularization is the superior approach. By isolating features (e.g., `:feature:login`, `:feature:dashboard`, `:feature:settings`) vertically, we minimize the impact of changes. If an engineer works on the Dashboard, the Login module does not need to be recompiled. This graph optimization is handled by Gradle's DAG (Directed Acyclic Graph) execution.

Architecture Note: A hybrid approach is often best. Use core modules (`:core:network`, `:core:database`) for shared infrastructure, but keep business logic strictly within vertical feature modules.

2. Centralized Dependency Management

Maintaining dependencies across 20+ modules leads to version drift. One module might use Retrofit 2.9.0 while another uses 2.6.0, causing runtime crashes or obscure compile errors. Historically, `buildSrc` (Kotlin DSL) was the solution, but it has a significant drawback: any change in `buildSrc` invalidates the build cache of the entire project.

The current industry standard is Gradle Version Catalogs (TOML). It provides a standardized way to define dependencies without the cache invalidation penalty of `buildSrc`. It allows for type-safe accessors in your build files while keeping configuration separate from logic.

Best Practice: Migrate from `ext` blocks or `buildSrc` to `libs.versions.toml` to improve build configuration performance and maintainability.

Below is a standard `libs.versions.toml` configuration for a modular setup:


# gradle/libs.versions.toml
[versions]
kotlin = "1.9.0"
coroutines = "1.7.3"
retrofit = "2.9.0"

[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }

[bundles]
networking = ["retrofit", "coroutines-core"]

In your module-level `build.gradle.kts`, implementation becomes cleaner and standardized:


dependencies {
    // Type-safe accessors generated by Gradle
    implementation(libs.kotlin.stdlib)
    implementation(libs.bundles.networking)
    
    // Project dependencies
    implementation(project(":core:ui"))
}

3. Decoupling Navigation Logic

One of the most challenging aspects of modularization is navigation. If `:feature:home` needs to navigate to `:feature:details`, adding a direct dependency creates tight coupling. If `:feature:details` also needs to reference `:feature:home`, you create a Circular Dependency, which Gradle will reject immediately.

To solve this, we must use an abstraction layer. A common pattern is to define a navigation interface in a shared module (or the feature's API module) and implement it in the `app` module or via Dependency Injection.

Strategy Pros Cons
Direct Dependency Simple to implement High coupling, circular dependency risk
Deep Links Complete decoupling No type safety, hard to pass complex objects
Interface/Navigator Type safety, decoupled Requires DI setup (Hilt/Dagger)

Here is an implementation of the Navigator pattern using an interface. This allows the Home feature to navigate to Details without knowing the implementation details of the Details module.


// In :core:navigation module
interface DetailNavigator {
    fun navigateToDetail(context: Context, id: String)
}

// In :feature:home module
@AndroidEntryPoint
class HomeFragment : Fragment() {
    @Inject lateinit var detailNavigator: DetailNavigator

    fun onUserClick(id: String) {
        // Feature Home knows NOTHING about Feature Detail class types
        detailNavigator.navigateToDetail(requireContext(), id)
    }
}

// In :app module (The glue)
class DetailNavigatorImpl @Inject constructor() : DetailNavigator {
    override fun navigateToDetail(context: Context, id: String) {
        // The app module knows about all features and connects them
        // Could use Intent or Jetpack Navigation here
        val intent = Intent(context, DetailActivity::class.java).apply {
            putExtra("ID", id)
        }
        context.startActivity(intent)
    }
}
Circular Dependency Risk: Never allow Feature A to depend on Feature B directly unless there is a strictly hierarchical relationship. Use a shared `:core` module or DI interfaces to bridge communication.

4. Managing Build Configurations

When you have 50+ modules, copying the same `android { ... }` configuration block (compileSdk, minSdk, kotlinOptions) into every `build.gradle` file creates massive technical debt. If you need to bump the `minSdk`, you have to edit 50 files.

To mitigate this, use Convention Plugins via `buildSrc` or an included build. This allows you to define a plugin (e.g., `com.mycompany.android.library`) that automatically applies the common configuration.


// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }

            extensions.configure<LibraryExtension> {
                compileSdk = 34
                defaultConfig {
                    minSdk = 24
                }
                // Centralized configuration ensures consistency across modules
            }
        }
    }
}

By adopting Convention Plugins, you treat your build logic as code, applying standard software engineering practices like inheritance and composition to your Gradle scripts. This drastically reduces the boilerplate code in individual module build files.

Conclusion: Complexity vs. Scalability

Modularization is not a silver bullet; it introduces significant complexity in configuration and dependency management. For small apps or MVPs, the overhead of managing multiple modules, Dagger/Hilt graphs, and navigation interfaces often outweighs the benefits. However, for long-term projects aiming for scalability, reduced build times, and clear team ownership boundaries, a multi-module architecture is a non-negotiable requirement. The key is to start with a modular mindset—separating logic into loose layers—even if you physically separate them into Gradle modules later.

Post a Comment