Modularization
A Gradle module is not a folder. It is a boundary the compiler enforces, a unit the build can parallelise, and a contract that a team can own.
Splitting an app into modules is usually justified with "it is cleaner" — a claim no build can enforce. That is not a reason the compiler can enforce. The reasons that hold up are narrower and more useful: build time, ownership, and the ability to prevent an accidental dependency.
TL;DR#
- A module is a subproject with its own compilation unit, dependencies and (optionally) its
own
minSdk/compileSdk. - Modules buy you: incremental build time, enforced boundaries, parallel team ownership, and reusable feature surfaces (including dynamic delivery).
- They cost you: more Gradle configuration, more
build.gradlefiles, and a dependency graph that can become a maze. - The useful split is by feature and by layer, not by type (
models,utils).1 - A module's public surface should be small: interfaces and destination functions, not screens.1
Mental model#
Think of a company with departments.
Departments exist so that a decision can be made without asking the whole company, and so that a change in one department does not require a meeting with all the others. A department that imports everything from every other department is not a department; it is a corridor.
Where the analogy breaks#
In a company, departments can talk informally. In Gradle, the boundary is compiled: a module that does not declare a dependency simply cannot see the class. That rigidity is the point — it is what turns an architectural intention into a build failure.
The big picture#
Text description of this diagram
A layered module graph. At the bottom, core modules — core:model, core:network, core:database, core:ui, core:designsystem — have no dependencies on feature or app modules. In the middle, feature modules such as feature:notes and feature:settings depend on core modules and expose navigation destinations. At the top, the app module depends on every feature module and owns the navigation graph, the DI graph and the application class. Arrows point downward only: a core module that depends on a feature module would be a cycle, and Gradle would reject it.
Deep dive#
What a module actually is#
// settings.gradle.kts
include(":app")
include(":feature:notes")
include(":feature:settings")
include(":core:model")
include(":core:data")
include(":core:designsystem")Six lines that define six compilation units. Each has its own build.gradle.kts, its own source set,
and its own visibility rules.
// feature/notes/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
}
android {
namespace = "com.example.notes.feature.notes"
compileSdk = 37
defaultConfig {
minSdk = 26
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:designsystem"))
implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}The implementation versus api distinction is the boundary's sharp edge, and it is the first thing
the build documentation warns about.2
| Configuration | Effect |
|---|---|
implementation | the dependency is not on the consumer's compile classpath |
api | the dependency is exposed transitively |
Default to implementation. Every api leaks a type into your public surface and makes the graph
harder to change.
The four reasons that hold up#
1. Build time. Gradle can compile independent modules in parallel and reuse outputs for unchanged ones. A monolith recompiles everything when one file changes; a well-split app recompiles one module and its dependents. The gain is real but sublinear in the number of modules — past a point, the Gradle configuration overhead eats it.
./gradlew :app:assembleDebug --profileThe profile report shows where the time goes. If configuration dominates compiling, adding modules will not help.
2. Boundaries. A :core:model module that does not depend on Android cannot accidentally acquire
a Context.1 That is a compile-time guarantee, not a code-review convention.
// core/model/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm) // a pure JVM module, not an Android library
}A pure-Kotlin model module is the single highest-value split: it makes the domain layer testable without Robolectric or a device, and it makes accidental Android dependencies impossible.
3. Ownership. A team can own :feature:notes without coordinating with the team that owns
:feature:settings. CODEOWNERS plus a module boundary is an organisational contract the build enforces.
4. Feature delivery. A dynamic feature module can be downloaded on demand.3 That is an AAB and Play Feature Delivery concern, but it starts with the app being split at feature boundaries.
The split that works#
:app
├── :feature:notes
│ ├── :feature:notes:api (destinations, no UI)
│ └── :feature:notes:impl (screens, ViewModels)
├── :feature:settings
├── :core:model (pure Kotlin)
├── :core:data (repositories)
├── :core:database (Room)
├── :core:network (HTTP client, DTOs)
├── :core:designsystem (theme, shared components)
└── :core:common (dispatchers, small utilities)Two patterns are visible:
- Feature by feature, not by layer. A
:screensmodule that holds every screen is a monolith with a different name. - api/impl split inside a feature. The
apimodule exposes routes and aNavGraphBuilderfunction; theimplmodule holds everything else. Other features depend onapi, never onimpl.
// feature/notes/api
@Serializable data object NotesGraph
@Serializable data class NoteDetail(val noteId: String)
fun NavGraphBuilder.notesGraph(navController: NavController) {
composable<NotesGraph> { /* delegate to impl via a factory */ }
}That indirection looks excessive for a small app and is exactly what allows a feature to be developed, tested and even delivered independently.
Version catalogs#
# gradle/libs.versions.toml
[versions]
# Pin the versions you have actually tested.
composeBom = "…"
hilt = "…"
kotlin = "…"
[libraries]
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
[plugins]
android-library = { id = "com.android.library", version.ref = "agp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }A catalog is not optional once you have more than a handful of modules: it is the only way to keep
versions consistent across twenty build.gradle.kts files. The type-safe accessors (libs.hilt.android)
are also checked at configuration time, so a typo fails early.
Modularization and build variants#
Modules multiply variants. A debug/release pair across fifteen modules is 30 variant combinations,
and some of them do not exist (a debug feature consumed by a release app). Gradle resolves this via
variant matching, which is why a module that declares a buildType the app does not have can fail
configuration.
The practical guidance: keep build types aligned across modules, and put flavour-specific code behind an interface in a core module rather than creating per-flavour feature modules.
Testing across modules#
A module boundary makes test scope explicit:
// core/data/build.gradle.kts
dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.core)
}testImplementation is the JVM test source set — fast, no emulator. androidTestImplementation needs a
device. A pure-Kotlin :core:model has only the first, which is why it is the best place for logic that
must be exhaustively tested.
Dynamic features and delivery#
A dynamic feature module is a module that can be downloaded on demand rather than installed with the base APK. It requires the app to be distributed as an App Bundle, and it requires the feature to be split at a boundary that makes sense — which is the same boundary modularization produces.
<dist:module
dist:instant="false"
dist:onDemand="true"
dist:title="@string/feature_notes">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>The constraint that catches people: a dynamic feature cannot be a dependency of the base module, because the base must run without it. That means every interaction with a dynamic feature goes through an interface the base declares and the feature implements — which is exactly the api/impl split, enforced by the delivery mechanism.
Measuring whether it worked#
Two numbers decide whether a split was worth it:
# Configuration and execution time per task
./gradlew :app:assembleDebug --profile
# What actually got recompiled for a one-file change
./gradlew :app:assembleDebug --dry-runIf a one-line change in a core module recompiles everything, the graph is too coupled and the split is not buying incrementalism. If configuration time grew by more than the compilation time shrank, the split was too fine-grained.
Where the boundaries actually leak#
Three leaks appear in almost every modularization effort.
Resources. A string or drawable in :core:designsystem referenced from :feature:notes works only
if the resource is public. Kotlin's internal does not apply to Android resources, so a library module
must mark shared resources with public.xml or they are inaccessible from other modules.
Navigation. A feature that wants to navigate to another feature needs a route it can reference. That is what the api/impl split provides; without it, features depend on each other and the graph becomes a mesh.
Initialisation order. A library that initialises in Application.onCreate forces the app module
to know about it. AndroidX Startup moves that into a provider dependency, so a module initialises
itself when it is present and does nothing when it is not.
What to do when the split is wrong#
Merging modules back is not a failure. The signals that a split was too fine-grained:
- A change to one feature requires edits in four modules.
- Two modules always change together in the same commit.
- A module has one class and a
build.gradle.kts. - Build time went up after the split.
Merging is a two-hour change: move the sources, delete the module, update settings.gradle.kts. That is
cheap enough that the right strategy is to split optimistically and merge when the evidence says so,
rather than to spend a month designing the perfect boundary.
The migration path#
Do not attempt a big-bang split. The sequence that works:
- Extract
:core:modelas a pure Kotlin module. It is mechanical and immediately useful. - Extract
:core:designsystem, so shared UI stops being copy-pasted. - Extract
:core:dataand:core:network, which usually reveals boundary violations. - Extract one feature at a time, starting with the least entangled.
- Add the
api/implsplit only where a feature is actually consumed by another.
At every step the app must still build and ship. A modularization project that blocks releases for a month is a modularization project that gets reverted.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
The tooling drove the practice: Gradle's incremental compilation made modules useful, AndroidX made independent versioning possible, and the App Bundle made feature delivery a reason to split. Android Gradle Plugin improvements — configuration cache, KSP, built-in Kotlin support — have steadily reduced the cost side of the trade.
Under the hood#
- the platform's own module system, Soong — a useful contrast to Gradle
- Android.bphow the framework itself declares modules
- PackageManager.javahow split APKs are represented at install time
Curiosity corner#
- The Android platform itself is modularized with Soong, and its module boundaries are stricter than Gradle's because they are enforced by the build system and by API checking.
- A pure Kotlin module cannot reference
android.*at all. That is why:core:modelis the best place to put business rules. - Configuration cache makes a big multi-module build noticeably faster on repeat runs, because configuration is skipped entirely.
Try it#
./gradlew :app:assembleDebug --profile
# then open build/reports/profile/*.html
./gradlew :app:dependencies --configuration debugRuntimeClasspath
Check yourself#
Check yourself
Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsGuide to Android app modularization | App architecturedeveloper.android.comaccessed 2026-10-27↩
- docsOptimize your build speeddeveloper.android.comaccessed 2026-10-27↩
- docsAbout Android App Bundlesdeveloper.android.comaccessed 2026-10-27↩
- docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩
- docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩