Modularization Explained
BUILD & TOOLING › Gradle
Modularization means splitting a codebase into separate Gradle modules instead of building everything inside one giant :app module. It is a staple system design question for senior Android roles because it forces you to reason about build performance, team ownership, and architectural boundaries all at once, not just API design.
The payoff interviewers expect you to name: faster incremental builds, since Gradle only recompiles modules whose inputs actually changed and can run independent modules' tasks in parallel; clearer ownership, so a team can work inside its own module without touching anyone else's; enforced boundaries, because a class marked internal in one module genuinely cannot be referenced from another module, the compiler stops you, not a style guide; reusability across app targets; and it is a prerequisite for Play Feature Delivery, which installs some modules on demand instead of shipping them in the base APK.
Splitting files into more Gradle projects is not automatically an improvement, though. What separates a good module from the monolith with extra build files is two properties: high cohesion, meaning everything inside the module is related and serves one clear responsibility, and low coupling, meaning the module knows as little as possible about other modules' internals. A module with neither of those is just bureaucracy.
A typical modular Android app settles into a handful of module roles. :app is the thin entry point: it owns root navigation and wires up the concrete DI implementations, often per build variant, for example a real repository for release and a fake one for androidTest. :feature-* modules each hold one screen or destination's UI and ViewModel, nothing more. :data modules hold repositories, data sources, and models. :core or :common modules hold cross-cutting reusable building blocks: networking, the design system, analytics, shared utilities, with no feature-specific logic of their own.
Keep :app as thin as possible. It should barely contain logic beyond wiring, since its whole job is to assemble the other modules, not to implement features itself.
// :core-network - reusable, no feature knowledge
@Singleton
class ApiClient @Inject constructor() { /* shared Retrofit/OkHttp setup */ }
Dependencies inside a modular app must flow strictly one way: :app depends on :feature-* modules, features depend on :data and :core, and :data depends on :core. The rule interviewers probe hardest is that features must never depend on each other directly.
// BAD - featureB/build.gradle.kts
dependencies {
implementation(project(":featureA")) // feature-to-feature coupling
}
// GOOD - featureB/build.gradle.kts
dependencies {
implementation(project(":data:user")) // shared data module instead
}
If two features seem to need each other, that is a sign the shared logic belongs in a :data or :core module both can depend on. This is not just a style preference either. Gradle requires the whole module graph to stay acyclic, so if you do introduce a cycle anywhere, even accidentally through a chain of several modules, Gradle refuses to build at all rather than silently pick a direction.
Inside a modular project, the implementation versus api choice controls the build-speed payoff of modularizing at all. Default every inter-module dependency to implementation: it keeps what a module depends on hidden from its own consumers, so changing an internal dependency only recompiles that one module, not everything downstream.
// :core-network/build.gradle.kts
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0") // internal detail, hidden
api("com.squareup.retrofit2:retrofit:2.11.0") // Retrofit types appear in our public API
}
Reach for api only when a dependency's own types genuinely appear in your module's public function signatures, return types or parameters that consumers need on their own classpath. If api shows up everywhere in your build files, you have quietly turned your module boundaries back into one big shared classpath, and lost most of modularization's build-speed benefit.
Encapsulation inside a module leans on a specific Kotlin keyword. A declaration marked with that keyword is visible everywhere inside its own Gradle module, but genuinely unreachable from any other module, even if it sits in an otherwise public package. That is a stronger guarantee than a naming convention or a lint rule, the compiler enforces it.
This scope is narrower than public, which any module can see, but broader than file-level private, which restricts a declaration to the single file it is written in. When you want a class to be an implementation detail of your module without hiding it from the rest of your own module's code, this keyword is the tool.
Since features cannot depend on each other, how does navigating from a list screen to a detail screen in a different feature module actually work? The answer is to pass only a primitive ID through navigation, never the full domain object, and let the destination re-fetch it itself from a shared :data module.
// :feature-list
navController.navigate("detail/${item.id}") // not the whole Item object
// :feature-detail ViewModel
class DetailViewModel(private val repo: ItemRepository) : ViewModel() {
fun load(id: String) = repo.getItem(id) // re-fetch, single source of truth
}
This avoids serializing whole objects into navigation arguments and, more importantly, keeps one single source of truth. The destination screen always reads current data from the shared repository rather than trusting a possibly stale object that was handed off mid-navigation. In practice the app module's navigation graph acts as the mediator that wires these destinations together, since it is the one module allowed to know about every feature.
Dependency injection wiring mirrors the same direction rule as everything else. Feature and data modules declare interfaces for what they need; the concrete implementation lives in whichever module owns that choice, usually :app itself, wired per build variant, for example debugImplementation versus releaseImplementation, or a fake bound for androidTest.
// :core-data - abstraction only, no Room or Firestore import
interface UserRepository { suspend fun getUser(id: String): User }
// :app - the only place that picks a concrete implementation
@Provides fun provideRepo(dao: UserDao): UserRepository = RoomUserRepository(dao)
This pattern is called dependency inversion: high-level modules depend on an abstraction, not on a concrete implementation, so swapping Room for Firestore means changing one @Provides function in :app. No feature module ever needs to recompile, because none of them ever referenced the concrete class in the first place.
Not every module needs to be an Android library module. When a module holds no Android-specific code at all, no resources, no manifest entries, no Android framework APIs, prefer a pure Kotlin or Java module over an Android library module.
// Heavier - Android library, manifest + resource processing even when unused
plugins {
id("com.android.library")
kotlin("android")
}
// Lighter - pure Kotlin module, no manifest, no resources
plugins {
id("java-library")
kotlin("jvm")
}
An Android library module goes through manifest merging and resource processing on every build, overhead that buys you nothing if the module is just plain Kotlin classes and interfaces. A :domain module full of use cases and models is the classic candidate: making it a pure Kotlin module keeps its incremental builds noticeably faster.
Modularization is not free, so module granularity itself is a design decision, not just a mechanical split. Too many fine-grained modules pile up build configuration overhead, boilerplate Gradle files, and per-module ceremony that can outweigh the benefit for a small codebase unlikely to grow much. Too few modules just recreates the monolith with extra folders and none of the enforced boundaries.
The right granularity balances against project size and team structure. A large team shipping many independent features benefits from finer splits that map to ownership; a small app maintained by two people probably does not need a dozen :core-* modules. There is no universally correct module count, only a trade-off to reason about out loud in an interview.
As the module count grows, repeating the same plugin setup, compile options, and common dependencies across dozens of build.gradle.kts files becomes error-prone, and the files drift out of sync with each other over time. The fix is a build-logic included build that defines convention plugins: shared Gradle logic centralized in one place.
// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig.minSdk = 24
}
}
}
Every feature module then replaces pages of copy-pasted boilerplate with a single line, plugins { id("convention.android.feature") }, and picks up any future change to that convention automatically instead of needing a mass find-and-replace across the whole project.
Play Feature Delivery lets some modules install on demand instead of shipping inside the base APK, and it inverts the usual dependency direction to make that possible. A dynamic feature module, built with the com.android.dynamic-feature plugin, depends on the base :app module. The base app does not depend back on the feature, it only registers the feature's name so Play can deliver it separately.
// :feature-ondemand/build.gradle.kts - dynamic feature declares dependency ON base :app
plugins { id("com.android.dynamic-feature") }
dependencies {
implementation(project(":app")) // feature depends on base
}
// :app/build.gradle.kts - base app only lists features, does NOT depend on them
android {
dynamicFeatures += setOf(":feature-ondemand") // registered here
}
This direction makes sense once you notice the base app has to exist and build successfully whether or not any dynamic feature is ever installed, so it cannot afford to depend on something optional.
One more piece of Gradle plumbing matters once a project has dozens of modules: keeping every module's dependency versions in sync. A version catalog, the gradle/libs.versions.toml file, centralizes dependency coordinates and versions in one typesafe place that every module shares.
# gradle/libs.versions.toml - one place for all versions
[versions]
retrofit = "2.11.0"
okhttp = "4.12.0"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
Every module then references the same alias, libs.retrofit, libs.okhttp, instead of typing out a version string by hand, so version drift between modules becomes impossible rather than something you catch in code review.