Modularization, Feature Modules & Build Tradeoffs
Architect large Android apps with clean module boundaries — the skill that separates seniors from juniors
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Android modularization is the practice of splitting a single large Gradle module into multiple smaller, independently compilable modules with clear dependency rules. Each module has a defined responsibility, reducing coupling, enabling parallel builds, and allowing teams to work independently without merge conflicts.
Real-world relevance
A large enterprise school management platform serving 500 schools grew from 1 module to 28 modules over two years. The :feature:attendance, :feature:grades, and :feature:timetable modules are owned by separate squads. Before modularization, a CSS change in the design system caused a full 11-minute recompile. After, only :core:design-system and the two feature modules using the changed component rebuild — 90 seconds total.
Key points
- Why modularize? — Single-module apps compile everything on every change. Modularization enables incremental compilation — only changed modules recompile — dramatically cutting build times on large enterprise codebases.
- Module types: app module — The :app module is the entry point — it declares the Application class, main Activity, and depends on all feature modules. It knows about everything but contains minimal logic itself.
- Module types: feature modules — :feature:login, :feature:dashboard, :feature:grades — each owns a vertical slice of the UI, ViewModel, and use cases for one product feature. They depend on :core modules but NOT on each other.
- Module types: core modules — :core:network, :core:database, :core:design-system, :core:common — shared infrastructure used by all feature modules. They must have no dependency on feature modules (no cycles).
- Module types: data modules — :data:user, :data:grades — own repositories and data sources for a specific domain. Feature modules depend on data modules via interfaces defined in :domain.
- API vs implementation visibility — Use 'api()' dependency only when the type from a dependency leaks into your module's public API. Use 'implementation()' for everything else — it keeps the dependency private and prevents accidental coupling downstream.
- No cross-feature dependencies — Feature modules must NEVER depend on each other directly. Communication between features goes through shared :core or :domain modules, or via navigation contracts.
- Navigation between modules — Use the Navigation Component with a top-level nav graph in :app, or define navigation contracts (interface + DeepLink) in :core:navigation so features expose destinations without referencing each other.
- Build speed improvements — Gradle's parallel execution and configuration caching mean only modules with changed sources recompile. A 200-module enterprise app can go from 8-minute full builds to 30-second incremental builds.
- Code ownership & team scalability — Each module can be owned by a separate team with independent review, versioning, and release cadence. Module boundaries enforce Conway's Law deliberately.
- Dynamic feature modules — Google Play's on-demand delivery allows :feature:ar-tour to be downloaded only when the user requests it, reducing initial APK size. Declared with com.android.dynamic-feature plugin.
- Pitfalls: over-modularization — Creating too many tiny modules increases Gradle configuration overhead. A 2-person team with 50 modules gets slower builds, not faster. Right-size modules to team size and feature boundaries.
Code example
// settings.gradle.kts — declare all modules
include(":app")
include(":core:network")
include(":core:design-system")
include(":core:common")
include(":domain:user")
include(":data:user")
include(":feature:login")
include(":feature:dashboard")
include(":feature:grades")
// :core:network/build.gradle.kts
plugins { id("com.android.library") }
dependencies {
// 'api' — OkHttp type leaks into callers' public APIs
api("com.squareup.okhttp3:okhttp:4.12.0")
// 'implementation' — Gson is internal detail, hidden from callers
implementation("com.google.code.gson:gson:2.10.1")
}
// :feature:grades/build.gradle.kts
plugins { id("com.android.library") }
dependencies {
implementation(project(":core:network"))
implementation(project(":core:design-system"))
implementation(project(":domain:user")) // interface only
// WRONG: implementation(project(":feature:login")) — never allowed!
}
// :app/build.gradle.kts — assembles everything
plugins { id("com.android.application") }
dependencies {
implementation(project(":feature:login"))
implementation(project(":feature:dashboard"))
implementation(project(":feature:grades"))
// :app knows all features; features do NOT know each other
}
// Navigation contract in :core:navigation
object Destinations {
const val GRADES = "feature/grades"
const val DASHBOARD = "feature/dashboard"
}
// Each feature registers its NavGraph via a NavigationProvider interface
// resolved by dependency injection — features never import each otherLine-by-line walkthrough
- 1. settings.gradle.kts include() calls — each string maps to a directory containing its own build.gradle.kts; Gradle discovers and wires them as project dependencies.
- 2. :core:network uses 'api(okhttp)' — because network module callers (like :data:user) likely expose OkHttp types (e.g., Response) in their own APIs; using 'api' propagates OkHttp to those callers automatically.
- 3. :core:network uses 'implementation(gson)' — Gson is only used internally for parsing; callers never see Gson types, so 'implementation' hides it and prevents accidental coupling.
- 4. :feature:grades depends on project(':core:network') and project(':domain:user') via 'implementation' — it uses these but does NOT expose their types in its own public surface.
- 5. The comment '// WRONG: implementation(project(':feature:login'))' — illustrates the cardinal rule: feature modules cannot reference sibling features. This would create a cycle if :feature:login ever needed to show a grade.
- 6. :app depends on all feature modules — it is the integration point; it adds each feature to the nav graph and provides the application-scoped DI container that wires everything together.
- 7. Destinations object in :core:navigation — defines route strings as constants in a module that both :app (for the nav graph) and individual features (for navigate() calls) can depend on without cross-feature coupling.
- 8. NavigationProvider interface — each feature module implements this interface and registers its NavGraph fragment; :app collects all implementations via DI (e.g., Hilt multibindings) at startup.
Spot the bug
// :feature:grades/build.gradle.kts
dependencies {
api(project(":core:network")) // Bug 1
api(project(":core:design-system")) // Bug 2
implementation(project(":feature:login")) // Bug 3
implementation(project(":data:grades"))
}
// GradeRepository.kt in :feature:grades
class GradeRepository(
private val api: GradeApi,
private val db: AppDatabase // Bug 4
) {
suspend fun getGrades(): List<Grade> = api.fetchGrades()
}
// :app/build.gradle.kts
dependencies {
implementation(project(":feature:grades"))
// :app now can use OkHttp directly without declaring it — Bug 5
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Guide to Android App Modularization (Android Developers)
- Dynamic Feature Modules (Android Developers)
- NowInAndroid — Real-world Multi-Module Architecture (GitHub / Google)
- Gradle — api vs implementation (Gradle Docs)
- Modularization at Scale — Slack Engineering (Slack Engineering Blog)