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