A single-module Android app works fine up to ~50,000 lines of code or ~10 engineers. Beyond that, build times balloon, ownership becomes unclear, and any change anywhere could break anything. Modularization is the engineering response to that scaling problem.
The Problems Modularization Solves
Slow builds: In a monorepo, a change to a utility function in utils/ rebuilds the entire app. In a modular app, only modules that depend on :utils rebuild.
Monolith: change → rebuild entire 200k LoC app → 3 min
Modular: change → rebuild :utils + :feature-feed → 25 sec
Merge conflicts: When everyone edits files in the same module, merge conflicts are constant. Module boundaries create natural ownership lines.
Unclear ownership: "Who's responsible for this?" In a monolith, the answer is often "everyone." In a modular app, :feature-payment has an owner.
Accidental coupling: Without enforced boundaries, any class can import any other. Over time, a tightly coupled "big ball of mud" emerges.
Types of Modules
The standard convention:
:app ← Application module (wires everything)
:feature:feed ← Feed feature (user-facing screen)
:feature:profile ← Profile feature (user-facing screen)
:feature:auth ← Auth feature (login/signup)
:data:articles ← Article data (Repository, Room, API)
:data:users ← User data
:domain:articles ← Use cases, domain models
:core:ui ← Shared UI components (design system)
:core:network ← OkHttp, Retrofit setup
:core:database ← Room database setup
:core:common ← Utilities, extensions
Dependency Rules
The dependency graph must be a DAG (Directed Acyclic Graph) — no cycles:
:app → :feature:* → :domain:* → :data:* → :core:*
Rules:
- :feature modules can depend on :domain and :core
- :domain modules can depend on :core
- :data modules can depend on :core
- :core modules depend on nothing else internal
- No :feature → :feature dependencies (communicate via navigation)
- No :data → :domain (domain defines interfaces; data implements them)
Enforcing Boundaries
Kotlin visibility: Mark internal implementations internal so they don't leak across module boundaries:
// In :data:articles module
// ✅ Public API — what other modules can use
class ArticleRepository internal constructor(/* ... */)
fun provideArticleRepository(...): ArticleRepository // exported via DI
// ✅ Internal implementation — invisible outside the module
internal class ArticleRemoteDataSource(private val api: ArticleApi) { ... }
internal class ArticleLocalDataSource(private val dao: ArticleDao) { ... }
API modules: Create a -api sub-module with only interfaces, and an -impl module with the implementation. Features depend on the -api module only.
Ownership Model
:feature:feed → Team: Feeds
:feature:messaging → Team: Messaging
:data:articles → Team: Content Platform
:core:ui → Team: Design System
:core:network → Team: Platform
Clear ownership means:
- PRs have clear reviewers
- Breaking changes require coordination across team boundaries
- Each team can move at their own speed
Build Speed: Before vs After
Real numbers from a medium-sized app (after modularization):
| Scenario | Before | After |
|---|---|---|
| Full clean build | 4:30 | 3:00 (modules can parallelize) |
Change in :feature:feed only | 4:30 | 0:45 |
Change in :core:common | 4:30 | 2:30 (many dependents) |
Run unit tests for :feature:feed | 2:00 | 0:20 |
Key Takeaways
- Modularization pays off when: build times > 60 sec, team > 5 engineers, or test cycles are slow
- Feature modules own user-facing screens; data modules own repositories; core modules provide shared infrastructure
- Enforce with
internalvisibility and clear dependency rules - Build speed improvement comes from Gradle's incremental compilation and parallelism
- Ownership clarity is often the most valuable benefit — not build speed