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.

Open Interactive Chapter →

Clean Architecture + MVVM (Model-View-ViewModel)

The standard Android architecture. Know the dependency graph cold — draw it in every interview.

LayerResponsibilityRule
UI (User Interface) (Compose / View)Renders state, emits user eventsNo business logic. Observe state only.
ViewModelHolds StateFlow, delegates to UseCasesNo Android context. Survives config change.
UseCases (Domain)Single business operation per classZero Android imports. Pure Kotlin.
RepositoryDecides: local DB (Database) or networkAbstracts data source from ViewModel.
Data SourcesRoom DB (Database) + Retrofit/OkHttpDumb I/O only. No business logic.
SeniorKnows MVVM; puts logic in ViewModel
StaffEnforces Clean Architecture boundaries; domain layer has zero Android imports; justifies why
PrincipalDefines the architecture standard for the org; enforces it via architecture lint rules and module boundaries

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
ScopeLifetimeUse For
@SingletonApp lifetimeRepositories, OkHttpClient, Database
@ActivityRetainedScopedViewModel lifetime (survives rotation)UseCases that are shared across screens
@ViewModelScopedSingle ViewModel lifetimeUseCases tied to one screen
@ActivityScopedActivity lifetimeNavigation controllers, Activity-level deps
@FragmentScopedFragment lifetimeFragment-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 TypeWhat It ContainsRule
:appApplication class, top-level DI (Dependency Injection), manifestDepends on all feature modules. Nothing depends on it.
:feature:XScreen UI (User Interface), ViewModel, feature-specific logicDepends on :core and :domain. Never on other features.
:domainUseCases, domain models, repository interfacesPure Kotlin. Zero Android dependencies.
:data:XRepository implementations, data sources, RoomImplements :domain interfaces.
:core:uiShared composables, design system componentsNo business logic. Stateless where possible.
:core:networkOkHttp, Retrofit setup, interceptorsNo feature-specific knowledge.
:core:commonExtensions, utilities, base classesNo Android framework deps if possible.
SeniorSplits code into feature modules
StaffEnforces :feature→:domain←:data dependency inversion; uses API/impl pattern; measures build time impact
PrincipalDefines the modularization strategy for 50+ engineers; sets dependency rules via Gradle dependency guards

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.

AspectMVVMMVI (Model-View-Intent)
StateMultiple StateFlows or LiveData streamsSingle immutable UiState object
User eventsUI (User Interface) calls ViewModel methods directlyUI (User Interface) sends Intent/Action sealed class
Side effectsAd-hoc via SharedFlow or callbacksExplicit SideEffect channel (nav, toasts)
TraceabilityHarder to trace all mutationsEvery state = f(previousState, action)
BoilerplateLessMore — but highly predictable
Best forSimple to medium screensComplex screens, many interactions
SeniorKnows MVVM
StaffCompares MVVM vs MVI and justifies choice for screen complexity
PrincipalSets the pattern for the team, enforces via architecture lint rules

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?WhatHow to Handle
YESRoom, DataStore, SharedPrefs, filesSource of truth must always be persistent storage, never in-memory only
YESSavedStateHandle in ViewModelStore navigation args and critical transient UI state here
YESrememberSaveable in ComposeUse for UI state that must survive both rotation and process death
NOViewModel, StateFlow, in-memory cache, remember { }Restore from Room/DataStore on relaunch; never assume these survive
SeniorKnows ViewModel survives rotation
StaffHandles process death explicitly with SavedStateHandle and Room restoration
PrincipalDesigns the full state restoration contract so no user action is ever silently lost

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()
    }
}
SeniorUses separate boolean flags for loading/error state
StaffUses sealed class UiState; understands impossible state prevention
PrincipalDefines the UiState pattern for the design system; builds lint rules to enforce it across teams

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.

Test your knowledge

This chapter includes 8 quiz questions covering all core concepts. Open the interactive experience to test yourself.

Start Quiz →