Modularization Explained

BUILD & TOOLING › Gradle

**Modularization** splits a codebase into separate Gradle modules instead of one giant :app. The payoff interviewers expect you to name: faster **incremental builds** (Gradle only recompiles modules whose inputs changed, and can run independent modules' tasks in parallel), clearer **ownership** (a team can own a module without touching others), **enforced boundaries** (you literally cannot call an internal class in another module), reusability across app targets, and it's a prerequisite for Play Feature Delivery's dynamic feature modules.

The two properties that make a module *good*, not just a module, are **high cohesion** (everything inside is related and serves one clear responsibility) and **low coupling** (it knows as little as possible about other modules' internals). A module with neither is just the monolith with extra Gradle files.

A typical modular Android app has a handful of module roles. **:app** is the thin entry point: root navigation, and the concrete DI implementations wired up (often per build variant, for example a real repository for release, a fake for androidTest). **:feature-*** modules hold one screen or destination's UI and ViewModel. **:data** modules hold repositories, data sources, and models. **:core**/:common modules hold cross-cutting reusable building blocks: networking, design system, analytics, shared utilities, with no feature-specific logic of their own.

// :core-network - reusable, no feature knowledge
@Singleton
class ApiClient @Inject constructor() { /* shared Retrofit/OkHttp setup */ }

Keep :app as thin as possible, it should barely have any logic of its own beyond wiring.

Dependencies must flow strictly one way: **:app depends on :feature-***, **features depend on :data and :core**, and **:data depends on :core**. The rule interviewers probe hardest: **features must never depend on each other**, and no cycle is allowed anywhere in the graph, Gradle fails the build outright if you introduce one.

// 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's a sign the shared logic belongs in a :data or :core module both can depend on.

Inside a modular project, the implementation versus api choice from Gradle basics matters even more, since it directly 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 the dependency's own types genuinely appear in your module's public function signatures. If api shows up everywhere, you've quietly turned your module boundaries back into one big shared classpath, losing most of modularization's build-speed benefit.

Since features can't depend on each other, how does navigating from a list screen to a detail screen in a *different* feature module work? The answer: pass only a **primitive ID** through navigation, never the full domain object, then have the destination re-fetch it 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 always reads current data from the shared repository rather than trusting a possibly-stale object handed off mid-navigation.

DI wiring mirrors the same direction rule. Feature and data modules declare **interfaces** for what they need; the concrete implementation lives in whichever module owns it, often :app itself, wired per build variant (debugImplementation versus releaseImplementation, or a fake for androidTest). This is **dependency inversion**: high-level modules depend on an abstraction, not a concrete implementation, so swapping Room for Firestore means changing one @Provides function in :app, no feature module ever recompiles.

// :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)

Modularization isn't free. 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. Too few modules just recreates the monolith with extra folders. The right granularity balances against project size and team structure; a shared **convention plugin** (in a build-logic included build) helps by centralizing repeated setup so each module applies one plugin instead of copy-pasting configuration.

Put together: modules gain you build speed and enforced boundaries only if you actually respect the dependency direction (app to feature to data to core, never feature-to-feature), default to implementation, pass IDs not objects across navigation, and invert dependencies so concrete choices live in one place. Get any of those wrong and modularization becomes ceremony without the payoff.

Back to Modularization