Android Engineer Hub

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 `StateFlow`. The Composable observes that flow with `collectAsStateWithLifecycle()` and renders. User actions become method calls on the ViewModel — `vm.onJobClick(id)`, `vm.onRefresh()` — never direct mutations. The ViewModel translates intents into repository calls and folds the results into a new `UiState`. A sealed interface for that state (Loading / Success / Error) makes rendering exhaustive and turns 'what should I show right now?' into a compiler-enforced question. **The data layer** owns repositories. A repository is the public API to one domain concept (jobs, user, settings) and is responsible for combining network, database, and in-memory sources behind a single `Flow`-shaped surface. Repositories are the seam where caching, offline-first behavior, and conflict resolution live. Now in Android's `OfflineFirstNewsRepository` is the canonical model: every read returns a `Flow` from Room, and a separate `sync()` method writes through from network to database. The UI never waits for the network directly — it observes the database and gets fresh data when sync completes. **The domain layer** is optional and should stay that way until you feel the pain of not having it. Use cases (`GetFollowableTopicsUseCase`) earn their keep when (a) the same orchestration logic appears in three or more ViewModels, or (b) the orchestration combines two or more repositories. Don't write a use case per repository method — that's Clean Architecture cosplay and it doubles your boilerplate without buying anything. The Now in Android team is explicit that use cases are added 'as needed,' not by default. Three principles enforce UDF in practice. First, **state is immutable** — the ViewModel rebuilds the entire `UiState` and emits it; consumers never mutate. Second, **events are one-shot, not state** — navigation requests and snackbar messages go through a `SharedFlow`, not a `StateFlow`, so they don't replay on rotation. Third, **the ViewModel survives configuration changes**; the Composable does not. The split is what lets you hold network results across rotation without persisting them. Once these three are non-negotiable, the rest of the architecture writes itself. **Navigation Compose** is the connective tissue that ties UDF across screens. Define a typed sealed-class route per destination (Kotlin Serialization annotations make routes typesafe end-to-end), inject the `NavController` only at the top-level `NavHost`, and pass lambda navigation callbacks down to screens (`onJobClick: (String) -> Unit`) rather than handing every screen the controller. This keeps screen Composables previewable in isolation and testable without a `NavHostController`. Now in Android wires `topLevelDestinations` as a typed enum and uses `NavigationSuiteScaffold` to adapt between bottom bar, navigation rail, and drawer based on window size class — one source of truth, three form factors, zero duplication.

Multi-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 : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("nowinandroid.android.library") pluginManager.apply("nowinandroid.hilt") pluginManager.apply("org.jetbrains.kotlin.plugin.serialization") dependencies { "implementation"(project(":core:ui")) "implementation"(project(":core:designsystem")) "implementation"(project(":core:data")) "implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) } } } ``` Pair convention plugins with a **version catalog** (`gradle/libs.versions.toml`), the official Gradle mechanism for centralizing versions. One file declares every dependency and version; modules reference them by alias (`libs.androidx.compose.runtime`). Bumping a version becomes a one-line PR instead of a 26-file find-and-replace. Annotation processing is the next performance lever. Replace **kapt** with **KSP** (Kotlin Symbol Processing) wherever the underlying processor supports it — Hilt, Room, and Moshi all do. KSP runs roughly 2× faster than kapt because it analyzes Kotlin symbols directly instead of generating Java stubs first. Apply `id("com.google.devtools.ksp")` and replace `kapt(...)` with `ksp(...)` in your dependencies block; the official KSP overview is at kotlinlang.org/docs/ksp-overview.html. Five Gradle settings cut build time in half on a real codebase. Put them in `gradle.properties` and forget them: `org.gradle.parallel=true` (build independent modules in parallel), `org.gradle.caching=true` (reuse outputs from previous builds), `org.gradle.configuration-cache=true` (cache the configuration phase — the biggest win on large module graphs), `org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC` (give Gradle enough memory), and `kotlin.incremental=true` (Kotlin incremental compilation, on by default but worth verifying). The Gradle docs at docs.gradle.org cover the configuration cache and remote build cache in depth — both are stable and production-ready as of Gradle 8.x. Measure before optimizing. `./gradlew --profile assembleDebug` produces an HTML report under `build/reports/profile` that shows where time is going. The Gradle Build Scan service (free at scans.gradle.com) is even better — publish a scan with `./gradlew build --scan` and you get a shareable URL with the full task timeline, dependency resolution, and cache hit rates. Most slow builds I have profiled are slow for one of three reasons: a module is configuring tasks it doesn't need, an annotation processor is running on too many modules, or the configuration cache is disabled. Fix one of those and you reclaim minutes per build.

Frequently 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

  1. Guide to app architecture
  2. Guide to Android app modularization
  3. Now in Android sample app
  4. Dependency injection with Hilt
  5. Gradle User Manual
  6. Kotlin Symbol Processing (KSP) overview

About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.