5 min read

Modularization That Survives a Growing Team

Splitting an Android app by layer looks clean and delivers nothing — every feature still touches every module. Split by feature instead, keep features independent of each other, and measure the blast radius of a one-line change.

AndroidArchitectureModularizationGradleBuild Performance
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Modules exist to make a change's blast radius small. Splitting by layer (:data, :domain, :ui) fails that test: a one-field change still edits three modules and rebuilds everything. Split by feature, keep :core:* modules for genuinely shared things, forbid feature-to-feature dependencies, and default to implementation. Then verify with a build scan — if the boundary didn't reduce what recompiles, it isn't paying for itself.

What modules are actually for

Three benefits, in the order they matter:

  1. Build speed — only what changed, plus its dependents, recompiles.
  2. Enforced boundaries — the compiler stops the coupling your code review misses.
  3. Team parallelism — clear ownership, fewer merge conflicts.

Every one depends on the same property: a change in one module should not force the rest to rebuild. If it does, you've added Gradle complexity and bought nothing.

Why the layer split fails

:app
:data      ← every repository for every feature
:domain    ← every model for every feature
:ui        ← every screen

Add a field to the search results screen and you edit :data, :domain, and :ui. Because every feature lives in all three, every feature's code sits in modules that just changed — so the compiler rebuilds broadly.

It's the same coupling as a single module, with extra build files. The diagram is tidy; the build graph is unchanged.

Split by feature

:app                        ← wires features together, owns navigation
:feature:search
:feature:player
:feature:profile
:core:model                 ← shared domain types, no dependencies
:core:data                  ← repositories, networking, database
:core:designsystem          ← theme, components
:core:testing               ← shared fakes, test rules

A change to search touches :feature:search, and only :feature:search recompiles. That's a real build win and it grows with the codebase.

The :core:* modules hold genuinely shared code. Keep them small and boring — :core:model in particular should have no dependencies at all, so changing it doesn't cascade.

Rule 1: features never depend on features

The rule that decides whether this survives two years:

// :feature:search/build.gradle.kts
dependencies {
    implementation(projects.feature.player)   // ← the beginning of the end
}

Once features import each other you have a distributed monolith: the build graph is a web, ownership blurs, and you've kept the coupling while paying for the modules.

When search needs to open the player, route through a contract that neither feature owns:

// :core:navigation — depends on nothing
data class PlayerRoute(val mediaId: String)

// :feature:search
navigator.navigate(PlayerRoute(item.id))

:app knows how to resolve routes to screens. Features stay leaves in the graph, which is exactly what makes them independently buildable.

Rule 2: implementation by default, api as a decision

dependencies {
    api(projects.core.model)          // deliberate: types appear in this module's public API
    implementation(projects.core.data) // default: an internal detail
}

api puts a dependency on the compile classpath of every consumer, so it widens the blast radius of every change to it. Most dependencies should be implementation; each api should be a choice someone can justify.

Rule 3: fix the build files before module 10

Forty modules means forty build.gradle.kts files. If you don't move that into convention plugins early, you will be editing forty files to bump Kotlin — and modularization gets blamed for the pain.

Do this at module five, not module forty. Retrofitting is mechanical but tedious, and the team will have developed strong opinions about modules by then based on an avoidable problem.

Rule 4: measure the blast radius

The only test that matters:

./gradlew :app:assembleDebug --scan     # baseline
# change one line in :feature:search
./gradlew :app:assembleDebug --scan     # what rebuilt?

The scan shows which tasks ran. If touching one feature recompiles nine modules, find the api declaration or the shared mutable module causing it. Common culprits: a :core:data that everything depends on and that changes constantly, or a :core:model that grew a networking dependency.

Don't over-split

The opposite failure is real. Thirty tiny modules add per-module Gradle configuration overhead, more build files, and navigation friction for anyone reading the code — often on a codebase where five modules would have captured the whole benefit.

My rough heuristic: a module should be something a person could own, and something you could describe in one sentence without "and". If you can't, it's either too small or two modules.

The honest summary

Modularization is a build-performance and ownership tool, not a purity exercise. The question is never "is this well-layered?" — it's "when someone changes one line, how much of this project has to be rebuilt?" Optimise for that answer and the rest tends to follow.

Keep reading