Architecture and Modularization: Building Android Apps That Survive Three Years of Feature Pressure
In short
The single largest predictor of whether an Android codebase will still be shippable in year three is how its architecture and module boundaries were drawn in year one. This deep-skill covers Modern Android Architecture (MAA) as Google now defines it — MVVM with a unidirectional data flow, a domain / data / UI layering inspired by Clean Architecture but stripped of ceremony, a multi-module structure where feature modules depend on core modules and never on each other, Hilt for compile-time-verified dependency injection, Navigation Compose for type-safe destinations, and a Gradle
Key takeaways
- MVVM with unidirectional data flow has one writer per state and one source of truth — events go up, state comes down.
- Repositories own caching and offline-first behavior; the UI observes the database and never waits on the network directly.
- Add a domain layer (use cases) only when orchestration is duplicated across ViewModels or combines multiple repositories.
- Multi-module: features depend on core modules and never on each other; domain types live in :core:model with no Android deps.
- Treat the Now in Android sample as the canonical reference — its 26-module layout, convention plugins, and offline-first repos are battle-tested.
- Pick the narrowest Hilt scope that satisfies the lifetime requirement, and inject dispatchers instead of hardcoding `Dispatchers.IO`.
- Convention plugins + version catalogs are how a 30-module project stays consistent under feature pressure.
- Replace kapt with KSP wherever supported, and turn on parallel + caching + configuration cache in gradle.properties.
Modern Android Architecture: MVVM + UDF
Google's official architecture guidance (developer.android.com/topic/architecture) now describes a three-layer model — UI, domain (optional), data — wired together by **unidirectional data flow (UDF)**. State flows down from data to UI; events flow up from UI to data. There is exactly one writer per piece of state, and every consumer reads from a single source of truth. This is MVVM as practiced in 2026, but the discipline is in the unidirectionality, not in the letter 'V' or 'M.' **The UI layer** holds Composables (or Fragments) and a `ViewModel` that exposes a single `StateFlowMulti-module structure: feature/core/data layers
A monolithic `:app` module compiles every file when any file changes and permits any class to depend on any other class. Multi-module solves both: Gradle compiles only the modules that changed (and their reverse dependencies), and the module graph is a hard wall that the compiler enforces. The Now in Android sample (github.com/android/nowinandroid) ships a 26-module structure that is the de facto reference for app teams. The taxonomy splits into three families. **`:app`** is a thin shim — application class, top-level navigation, dependency aggregation. It depends on every feature module and every core module it needs to wire. **Feature modules** (`:feature:home`, `:feature:foryou`, `:feature:topic`, `:feature:bookmarks`) each own one user-facing surface — Composables, ViewModels, navigation routes. **Core modules** are reusable infrastructure: `:core:data` (repositories), `:core:network` (Retrofit/OkHttp), `:core:database` (Room), `:core:datastore` (preferences), `:core:domain` (use cases), `:core:designsystem` (theme, components), `:core:model` (data classes), `:core:common` (utilities), `:core:ui` (shared Composables), `:core:testing` (test fakes and rules). ``` :app ──────────► :feature:home ─────────► :core:data │ │ │ │ ▼ ├────► :core:network │ :core:designsystem │ │ │ ├────► :core:database ├──────────► :feature:foryou ────────────────┤ │ │ └────► :core:datastore │ ▼ │ :core:ui ─────► :core:model └──────────► :feature:bookmarks ──► :core:data ``` Three rules keep the graph clean. **Features never depend on features** — if two features need shared code, that code belongs in a core module. This is what makes feature teams independent and prevents the diamond-dependency tangles that ossify older codebases. **Dependencies flow inward**: `:feature:*` → `:core:data` → `:core:network`, never the reverse. **Domain types live in `:core:model`** and have no Android dependencies, so they can be shared with JVM tests and (if you ever go there) Kotlin Multiplatform targets. Two more flavors of module solve specific pains. **`:sync:work`** isolates WorkManager logic so feature modules don't pull in androidx.work transitively. **`:lint`** holds custom Android Lint rules that enforce architectural invariants — Now in Android uses one to flag direct usage of `Dispatchers.Default` outside of explicitly-injected dispatchers. Custom lint is how you turn 'team agreement' into 'compile-time error,' which is the only form of agreement that survives onboarding new engineers. Migration tactics for legacy codebases. Don't try to extract everything at once — start by carving `:core:model`, then `:core:network`, then one feature. Each extraction takes a day and the build speedup is measurable after the first two. The Android docs page on modularization (developer.android.com/topic/modularization) has a step-by-step refactor guide. Track build time with `./gradlew --profile assembleDebug` and post the trend in your team channel — build performance is invisible until you make it visible, and then everyone starts caring about it.Hilt DI: scopes, modules, qualifiers
Hilt is Dagger 2 with opinions and code-generation conveniences specific to Android. It generates the component hierarchy at compile time, ties it to Android lifecycle classes (`Application`, `Activity`, `Fragment`, `ViewModel`, `Service`), and verifies the entire dependency graph at build time so you never see a `MissingBindingException` in production. The official guide is at developer.android.com/training/dependency-injection/hilt-android. Hilt's scope hierarchy mirrors Android's lifecycle. `@Singleton` lives for the Application's lifetime. `@ActivityRetainedScoped` survives configuration changes (this is what backs `@HiltViewModel`). `@ActivityScoped` dies on rotation. `@ViewModelScoped` is per-ViewModel-instance, ideal for repositories or use cases owned by one screen. `@FragmentScoped` and `@ServiceScoped` are the obvious analogues. Pick the **narrowest** scope that satisfies the lifetime requirement — wider scopes mean longer-lived objects and more leaked-context risk. Three module patterns cover ninety percent of real apps. `@Binds` for interface-to-implementation wiring (zero-cost at runtime). `@Provides` for third-party types you don't own (Retrofit, OkHttp, Json). `@Qualifier` annotations for disambiguating two bindings of the same type — `@Dispatcher(IO)` vs `@Dispatcher(Default)` is the canonical example. ```kotlin @Module @InstallIn(SingletonComponent::class) abstract class DataModule { @Binds @Singleton abstract fun bindsJobsRepository( impl: OfflineFirstJobsRepository ): JobsRepository } @Qualifier annotation class IoDispatcher @Module @InstallIn(SingletonComponent::class) object DispatchersModule { @Provides @IoDispatcher fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO } ``` Three rules separate practitioners from beginners. First, **inject dispatchers, never call `Dispatchers.IO` directly**. Tests need a `StandardTestDispatcher`, and a hardcoded reference makes the class untestable. Now in Android does this everywhere. Second, **prefer constructor injection over field injection.** Constructor injection is testable without Hilt; field injection (`@Inject lateinit var`) only works inside Hilt-aware classes (`Activity`, `Fragment`, `Service`). Reserve field injection for framework entry points where the framework constructs the instance. Third, **don't put logic in modules.** A module is a wiring file. If your `@Provides` function has more than one statement, the work probably belongs in the class being provided. Two advanced features pay for themselves on real teams. `@HiltViewModel` automates ViewModel injection — annotate the class, declare `@Inject` constructor, and `hiltViewModel()` in Compose just works. `@EntryPoint` is the escape hatch for code that Hilt can't reach (a content provider, a library that constructs your class for you) — declare an interface annotated with `@EntryPoint`, and Hilt will give you a way to fetch dependencies from the right component manually. Use it sparingly; if you reach for `@EntryPoint` more than once or twice, your component boundaries are wrong. Module organization mirrors module structure. Each `:core:*` module ships its own DI module under a `di/` package — `:core:network` provides Retrofit, `:core:database` provides the Room database and DAOs, `:core:datastore` provides DataStore preferences. Feature modules rarely declare their own DI modules; they consume bindings from core. This mirrors the dependency graph and makes it obvious where a binding lives when you trace a `MissingBindingException`. Test setups use `@TestInstallIn(replaces = [...])` to swap real bindings for fakes — Hilt's official testing API and the cleanest path to instrumented tests that exercise real ViewModels against fake repositories.Build performance: Gradle convention plugins, KSP, parallel builds
A 30-module project with no build configuration discipline will have `build.gradle.kts` files that drift apart over months. One module forgets to enable Compose. Another upgrades AGP independently. Convention plugins fix this. A **convention plugin** is a Gradle plugin you write inside your own repo, in a `build-logic` included build, that captures one configuration concern (Android library defaults, Compose setup, Hilt wiring) so feature modules apply it with one line: `id("nowinandroid.android.feature")`. The Now in Android sample uses this pattern across all 26 modules and it is the single biggest reason that codebase scales. ```kotlin // build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt class AndroidFeatureConventionPlugin : PluginFrequently asked questions
- Common pitfall
- Writing a use case for every repository method — Clean Architecture cosplay that doubles boilerplate without adding value.
- Common pitfall
- Letting feature modules depend on each other, which collapses the modularization benefits and recreates a monolith.
- Common pitfall
- Hardcoding `Dispatchers.IO` inside ViewModels or repositories, breaking unit testability.
- Common pitfall
- Using `@Singleton` everywhere instead of picking the narrowest scope — leaks Activity context if you're not careful.
- Common pitfall
- Field-injecting (`@Inject lateinit var`) into classes you control instead of using constructor injection.
- Common pitfall
- Skipping convention plugins and copy-pasting `build.gradle.kts` blocks until they drift module by module.
- Common pitfall
- Leaving kapt in place for processors that have a KSP equivalent — you are paying 2× build time for no reason.
- Common pitfall
- Disabling the Gradle configuration cache because one plugin doesn't support it, instead of filing the bug and isolating the plugin.
Sources
About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.