Chapter 2
App Architecture
Clean Architecture · MVVM (Model-View-ViewModel) · Hilt · Modularization · Navigation
Ready to practise interactively?
Explore this chapter with quizzes, diagrams, and real-world examples in the full interactive experience.
Clean Architecture + MVVM (Model-View-ViewModel)
The standard Android architecture. Know the dependency graph cold — draw it in every interview.
| Layer | Responsibility | Rule |
|---|---|---|
| UI (User Interface) (Compose / View) | Renders state, emits user events | No business logic. Observe state only. |
| ViewModel | Holds StateFlow, delegates to UseCases | No Android context. Survives config change. |
| UseCases (Domain) | Single business operation per class | Zero Android imports. Pure Kotlin. |
| Repository | Decides: local DB (Database) or network | Abstracts data source from ViewModel. |
| Data Sources | Room DB (Database) + Retrofit/OkHttp | Dumb I/O only. No business logic. |
Interview tip: Dependencies point inward only. Domain layer knows nothing about Android, Room, or Retrofit. This makes UseCases unit-testable with zero mocking of Android classes.
Dependency Injection — Hilt at Scale
Not just 'use Hilt'. Know component scopes and how DI (Dependency Injection) interacts with modularization.
- Testing with Hilt — replace modules with @TestInstallIn; use fake repositories, not mocks
- Multi-module DI — define interfaces in :core module; implement in :feature modules; bind via Hilt modules
- Avoid god components — if your @Singleton module is huge, split by feature domain
| Scope | Lifetime | Use For |
|---|---|---|
| @Singleton | App lifetime | Repositories, OkHttpClient, Database |
| @ActivityRetainedScoped | ViewModel lifetime (survives rotation) | UseCases that are shared across screens |
| @ViewModelScoped | Single ViewModel lifetime | UseCases tied to one screen |
| @ActivityScoped | Activity lifetime | Navigation controllers, Activity-level deps |
| @FragmentScoped | Fragment lifetime | Fragment-specific dependencies |
Recommended Libraries
- Hilt — Jetpack's DI framework built on Dagger. Compile-time safety, Android-aware scopes. Recommended for Android.
- Koin — Kotlin-first DI with simple DSL. No code generation, runtime resolution. Easier learning curve than Hilt.
- Dagger — Google's compile-time DI framework. Hilt is built on it. Use directly only for complex custom setups.
Modularization at Scale
How to structure a large codebase. Heavily tested at Google, Meta, and any company with 50+ engineers on Android.
- API (Application Programming Interface)/impl pattern — :feature:login:api defines interfaces; :feature:login:impl provides them. Other modules depend on :api only, never :impl.
- Build time — modularization enables parallel compilation and Gradle build caching. A 6500-module project can reduce incremental build time from minutes to seconds.
- Dependency inversion — features communicate via :core interfaces, never direct dependencies.
| Module Type | What It Contains | Rule |
|---|---|---|
| :app | Application class, top-level DI (Dependency Injection), manifest | Depends on all feature modules. Nothing depends on it. |
| :feature:X | Screen UI (User Interface), ViewModel, feature-specific logic | Depends on :core and :domain. Never on other features. |
| :domain | UseCases, domain models, repository interfaces | Pure Kotlin. Zero Android dependencies. |
| :data:X | Repository implementations, data sources, Room | Implements :domain interfaces. |
| :core:ui | Shared composables, design system components | No business logic. Stateless where possible. |
| :core:network | OkHttp, Retrofit setup, interceptors | No feature-specific knowledge. |
| :core:common | Extensions, utilities, base classes | No Android framework deps if possible. |
Interview tip: Describe the dependency graph direction: :app → :feature → :domain ← :data. Explain why :feature modules must never depend on each other directly.
Navigation Architecture
Single Activity + Compose Navigation is the modern standard. Know the full picture.
- Single Activity — one Activity, all screens are composables. Reduces back-stack complexity.
- NavHost + NavController — define routes as sealed classes or strings; navigate by route
- Nested navigation graphs — group related screens (auth flow, onboarding flow, settings)
- Deep links — declare in NavHost; handle both cold-start and in-app deep links
- Bottom tab navigation — each tab gets its own nested NavGraph with independent back stack
- Back stack management — use popUpTo and launchSingleTop to prevent duplicate destinations
- Passing arguments — use route path params or saved state handle; avoid passing complex objects
Interview tip: How do you handle deep links that arrive when the app is not running? Answer: NavController processes the intent in the Activity; NavDeepLinkBuilder reconstructs the back stack correctly.
MVVM (Model-View-ViewModel) vs MVI (Model-View-Intent)
Both are valid. Know the distinction — MVI is increasingly common in Compose-heavy codebases.
| Aspect | MVVM | MVI (Model-View-Intent) |
|---|---|---|
| State | Multiple StateFlows or LiveData streams | Single immutable UiState object |
| User events | UI (User Interface) calls ViewModel methods directly | UI (User Interface) sends Intent/Action sealed class |
| Side effects | Ad-hoc via SharedFlow or callbacks | Explicit SideEffect channel (nav, toasts) |
| Traceability | Harder to trace all mutations | Every state = f(previousState, action) |
| Boilerplate | Less | More — but highly predictable |
| Best for | Simple to medium screens | Complex screens, many interactions |
Process Death & State Restoration
Process death is silent — Android kills your process without calling onDestroy. ViewModel is destroyed. Only SavedStateHandle and persistent storage survive.
- Config change vs process death — ViewModel survives rotation but NOT process death. SavedStateHandle handles both cases correctly.
- Keep Bundles small — SavedStateHandle is backed by a Bundle; keep under 500KB; store IDs not full objects
| Survives Process Death? | What | How to Handle |
|---|---|---|
| YES | Room, DataStore, SharedPrefs, files | Source of truth must always be persistent storage, never in-memory only |
| YES | SavedStateHandle in ViewModel | Store navigation args and critical transient UI state here |
| YES | rememberSaveable in Compose | Use for UI state that must survive both rotation and process death |
| NO | ViewModel, StateFlow, in-memory cache, remember { } | Restore from Room/DataStore on relaunch; never assume these survive |
State Machine Design with Sealed Classes
Complex screens have states that are mutually exclusive — loading, content, error, empty. Model them as sealed classes, not a tangle of boolean flags. Booleans compose badly: isLoading=true && isError=true is an impossible state you have to guard against everywhere.
- Sealed classes make impossible states unrepresentable — the compiler enforces exhaustive handling
- Single UiState sealed class replaces isLoading + isError + data — three booleans = 8 combinations, most invalid
- when() on a sealed class is exhaustive — adding a new state causes a compile error everywhere, not a silent bug
- data object for singletons (Loading, Empty); data class for states with payloads (Success, Error)
- State transitions — always emit Loading before async work; never leave the UI in an ambiguous state
// Sealed class state machine — impossible states are unrepresentable
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val code: Int, val message: String, val retryable: Boolean) : UiState<Nothing>
data object Empty : UiState<Nothing>
}
// ViewModel — single source of truth
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow<UiState<List<Post>>>(UiState.Loading)
val state: StateFlow<UiState<List<Post>>> = _state.asStateFlow()
fun load() {
viewModelScope.launch {
_state.value = UiState.Loading
_state.value = try {
val posts = repo.getPosts()
if (posts.isEmpty()) UiState.Empty else UiState.Success(posts)
} catch (e: HttpException) {
UiState.Error(e.code(), e.message(), retryable = e.code() != 403)
}
}
}
}
// Compose UI — exhaustive when() enforces all states are handled
@Composable
fun FeedScreen(state: UiState<List<Post>>, onRetry: () -> Unit) {
when (state) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> LazyColumn { items(state.data) { PostItem(it) } }
is UiState.Error -> ErrorView(state.message, if (state.retryable) onRetry else null)
is UiState.Empty -> EmptyStateView()
}
}Interview tip: When designing a screen, say: I model UI state as a sealed interface with Loading, Success, Error, Empty. This prevents impossible flag combinations and lets Compose's when() enforce exhaustive handling at compile time.