Zygote
App architecturenerd

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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.gradle files, 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#

Loading diagram…
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

ConfigurationEffect
implementationthe dependency is not on the consumer's compile classpath
apithe 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 --profile

The 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 :screens module that holds every screen is a monolith with a different name.
  • api/impl split inside a feature. The api module exposes routes and a NavGraphBuilder function; the impl module holds everything else. Other features depend on api, never on impl.
// 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-run

If 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:

  1. Extract :core:model as a pure Kotlin module. It is mechanical and immediately useful.
  2. Extract :core:designsystem, so shared UI stops being copy-pasted.
  3. Extract :core:data and :core:network, which usually reveals boundary violations.
  4. Extract one feature at a time, starting with the least entangled.
  5. Add the api/impl split 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#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

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#

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:model is 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#

Profile a build

./gradlew :app:assembleDebug --profile # then open build/reports/profile/*.html

See the module dependency graph

./gradlew :app:dependencies --configuration debugRuntimeClasspath

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

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.

  1. docsGuide to Android app modularization | App architecturedeveloper.android.comaccessed 2026-10-27↩
  2. docsOptimize your build speeddeveloper.android.comaccessed 2026-10-27↩
  3. docsAbout Android App Bundlesdeveloper.android.comaccessed 2026-10-27↩
  4. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  5. docsJetpackdeveloper.android.comaccessed 2026-10-27↩
  6. docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩