A Repository is the one class the rest of the app asks for a given kind of data: it hides whether the data came from the network, Room, DataStore or memory, and it returns domain models.
Single source of truth: every screen reads the same place, so two screens cannot show two versions of the same user.
Offline-first comes naturally: serve the cached rows at once and refresh from the network behind them.
Testable: the ViewModel takes the repository interface, and a test passes a fake.
One place for policy: caching, retries and DTO-to-domain mapping live here instead of in every screen.
The rule that keeps it honest: it returns domain models, never Retrofit DTOs or Room entities, so a backend rename stops at the mapper.
AndroidMiddle
Android Architecture
MVVM, Clean Architecture, Repository, ViewModel, UseCase, UDF
Questions closed
0 / 22
- 0Review
- 6Asked often
Questions
State flows down from the ViewModel to the UI, and events flow up from the UI to the ViewModel; the UI never changes state itself, it only asks the ViewModel to.
**One immutable UiState**: a data class the ViewModel exposes as StateFlow<UiState>; the screen collects it with collectAsStateWithLifecycle().
Events go up as calls: onAction(CartAction.Remove(id)) or plain methods; only the ViewModel writes _state.
One place to look: when the screen shows something wrong, there is exactly one writer to inspect, and every change can be logged.
Easy to test and preview: the UI is a function of state, so you render it by passing a state object, no ViewModel needed.
It is also how Compose itself works: the same state always draws the same UI.
The SDK-internal patterns worth naming are Proxy, Chain of Responsibility, Composite, Decorator and Facade, each tied to a class you have used: retrofit.create, OkHttp interceptors, ViewGroup, ContextWrapper, Glide.with(...).
Proxy: retrofit.create(Api::class.java) returns a dynamic proxy that turns each interface call into an HTTP request.
Chain of Responsibility: OkHttp Interceptors, each calling chain.proceed(request); touch events travelling down the ViewGroup tree.
Composite: a ViewGroup is a View that contains Views, so a screen is a tree handled uniformly.
Decorator: ContextWrapper wraps a Context and forwards calls; Activity and Application are ContextWrappers.
Facade: Glide.with(context).load(url).into(view) hides caching, decoding and threading behind one call.
Plus the usual Builder (NotificationCompat.Builder), Factory (ViewModelProvider.Factory), Observer (LiveData, Flow) and Adapter (RecyclerView.Adapter; Retrofit's CallAdapter adapts Call<T> to Flow or Rx types and a Converter maps the body to your types).
A lifecycle-aware component observes a LifecycleOwner and starts or stops itself, so the Activity no longer has to call it from onStart and onStop; for the app as a whole you observe ProcessLifecycleOwner.
**Lifecycle and LifecycleOwner**: the owner exposes its current state (CREATED, STARTED, RESUMED…). ComponentActivity, Fragment (and its viewLifecycleOwner) and every NavBackStackEntry are owners.
**DefaultLifecycleObserver**: override onStart, onStop and the rest, register with lifecycle.addObserver(...). A new observer is immediately brought up to the owner's current state.
What is built on it: LiveData delivers only to STARTED observers; repeatOnLifecycle and collectAsStateWithLifecycle() stop collecting below STARTED.
**ProcessLifecycleOwner** (artifact lifecycle-process) gives one lifecycle for the whole app: ON_START when the first Activity starts, ON_STOP after the last one stops. The stop is delayed by about 700 ms so a rotation does not look like going to the background, and ON_DESTROY is never sent.
They differ in how the UI learns that something changed: MVC and MVP push changes into the view through method calls, MVVM exposes observable state, and MVI narrows that to one immutable state object changed only through declared actions.
MVC: on Android the Activity became both view and controller, giving the "massive Activity" whose logic could not be tested without a device. Mostly history now.
MVP: the view is an interface (showLoading(), showItems(list)) and the presenter calls it. Testable, but attach and detach are manual lifecycle work, the presenter does not survive rotation by itself, and state is split between presenter fields and what the view happens to show.
MVVM: the ViewModel exposes observable state and has no reference to the view; Jetpack ViewModel surviving rotation is half of why it won. Its weak spot: nothing forces one state object, so five separate flows that disagree are the usual bug.
MVI: MVVM plus two rules: one immutable state per screen, and every change described by an action that a reducer applies. Easy to test and log, impossible combinations are hard to render; the cost is boilerplate and a state copy on every change.
A Compose app converges on MVVM in name and MVI in shape: one StateFlow<UiState> of an immutable data class, a sealed action type passed to onAction, and updates through _state.update { }. That needs no library; Orbit and similar are optional.
Code is split into presentation, domain and data layers, and dependencies point only inward: the UI depends on the domain, the data layer implements interfaces the domain declares, and the domain imports nothing from Android, Retrofit or Room.
The layers
Presentation: Compose UI plus ViewModels. Renders state, forwards events, holds no business rules.
Domain: entities, repository interfaces and UseCases, in pure Kotlin. This is where UseCases live. Google's architecture guide calls this layer optional; Clean Architecture makes it the centre.
Data: repository implementations, Retrofit and Room data sources, mappers from DTOs and entities to domain models.
The dependency rule
Data flows both ways, but source-code dependencies point inward. The domain declares interface OrderRepository, the data layer implements it, and DI (Hilt) binds the two in :app.
Put the domain in its own pure-Kotlin Gradle module and the rule becomes a compile error instead of a review comment: android.* is simply not on its classpath, and its tests run on the JVM.
When it pays
Large, long-lived apps with several teams and real business rules.
On a small app it is ceremony. Google's recommendation (a UI layer and a data layer, with UseCases added only where a ViewModel starts doing real work) follows the same principle with fewer classes, and saying so is a strong answer.
On a configuration change the Activity is recreated but its ViewModelStore is handed to the new instance, so the same ViewModel comes back; it is cleared only when the owner finishes for good, and nothing in it survives process death.
Why it survives rotation
ComponentActivity, Fragment and every NavBackStackEntry are ViewModelStoreOwners. by viewModels() asks the owner's store for an instance by key and creates one only if it is missing.
During rotation the store is passed across as a non-configuration instance. On ON_DESTROY the Activity clears the store only if isChangingConfigurations is false.
When it does not
finish(), the back button, a popped navigation entry: onCleared() runs and viewModelScope is cancelled.
Process death: the system kills the app in the background and all memory is gone. Small state that must come back (an id, query text, the selected tab) goes into SavedStateHandle, which is saved into the instance-state Bundle; real data belongs in Room or DataStore.
Constructor parameters
The framework creates the ViewModel, so a ViewModelProvider.Factory calls the constructor. By hand: viewModelFactory { initializer { ... } }, where createSavedStateHandle() gives you the handle.
With Hilt: @HiltViewModel plus @Inject constructor; SavedStateHandle and navigation arguments arrive automatically.
Never pass a View, Activity or Fragment in: the ViewModel outlives all of them.
It leaks the Activity across rotation, crashes when it shows the Toast from a background thread, keeps working after the screen is gone, and lets the UI write its state; a ViewModel should depend on nothing with a UI lifetime and leave UI work to the screen.
**Holds an Activity: the ViewModel outlives every Activity instance it sees. After a rotation it still references the destroyed one, and through it the whole view hierarchy: a leak LeakCanary reports on the first rotation.
GlobalScope**: not tied to the ViewModel, so the work continues after onCleared(). viewModelScope is cancelled when the ViewModel is cleared.
Toast off the main thread: GlobalScope.launch runs on Dispatchers.Default, and Toast.makeText there throws "Can't toast on a thread that has not called Looper.prepare()". Showing a toast is a UI decision anyway: emit an event and let the screen show it.
**Public MutableStateFlow**: any caller can write the state. Expose asStateFlow() and change it with _state.update { }, which is also atomic, unlike state.value = state.value.copy(...).
String resolved in the ViewModel: the text is frozen in the locale active at that moment. Hold a @StringRes id or a reason and resolve it in the UI.
AndroidViewModel gives you the Application, which cannot leak an Activity, but it still resolves resources against the app's configuration and pulls Robolectric into tests. Inject what you actually need (a repository, a clock) instead of any Context.
Both hold a current value the UI observes. LiveData is lifecycle-aware on its own but tied to Android and the main thread; StateFlow is plain Kotlin with an initial value and every Flow operator, and gets its lifecycle from repeatOnLifecycle or collectAsStateWithLifecycle().
Lifecycle: observe(owner) delivers only while the owner is STARTED and unsubscribes at DESTROYED. A StateFlow knows nothing about lifecycles, so it is collected under repeatOnLifecycle(STARTED); a plain lifecycleScope.launch { collect } keeps updating a stopped screen.
Value and equality: StateFlow<T> always has a value and drops one equal to the current; LiveData.value is T?, null until set unless you pass an initial value, and every setValue is delivered unless you add distinctUntilChanged().
Operators: LiveData has map and switchMap, run on the main thread; Flow has combine, flatMapLatest, debounce, catch and stateIn, and Room and DataStore return Flow directly.
New apps pick StateFlow because one type carries data from Room to the UI with no Android dependency, so it also works in pure-Kotlin and KMP modules. LiveData is not deprecated, and a View-based codebase full of it needs no rewrite: asLiveData() and asFlow() bridge the two during a migration.
The observer gets 0, then 3, then 2: setValue delivers at once, while postValue only stores the latest value and runs one main-thread task later, so 1 is lost and the stale 2 overwrites 3.
**postValue**: safe from any thread, but it coalesces: only the latest pending value is delivered, in one main-thread task, so intermediate values are lost and the result can land after a later setValue.
**setValue** (.value =): main thread only. From a background thread it throws IllegalStateException: Cannot invoke setValue on a background thread. Every call is dispatched at once, even with an equal value.
Lifecycle-aware: observers receive values only while STARTED or RESUMED, and a new observer gets the latest value at once.
StateFlow has no postValue/setValue split: update { } is safe from any thread. LiveData is not deprecated.
A UseCase is one business operation expressed as a single callable class, and it earns its place when the logic is shared between screens or too involved for a ViewModel to own.
The shape is deliberately boring: one public operator fun invoke(...), a name in the product's language (PlaceOrder, ObserveUnreadCount), repository interfaces in the constructor, domain models or a Flow out, no Android imports and no stored state, so it unit-tests on the JVM in milliseconds.
What belongs in one
Orchestration across several repositories: a user, their subscription and their feature flags folded into one answer.
A business rule no single repository owns: can this order be placed, what is the discount.
A transformation two ViewModels would otherwise duplicate.
What does not
A pass-through. A class whose whole body is repo.getUser(id) costs a file, a DI binding and a test and buys nothing; call the repository from the ViewModel.
UI concerns: formatting, building strings, deciding whether a button is visible.
Anything that needs a Context.
One operation per class is the point: a UserUseCases class with ten methods is a repository under another name. Google treats the domain layer as optional, and "add it once ViewModels start sharing or growing real logic" is a defensible answer.
A sealed hierarchy makes mutually exclusive phases impossible to confuse, while a single data class handles the far more common case where content, a refresh and an error are all true at the same time.
Sealed hierarchy
Wins when the phases really exclude each other: a first load shows a spinner, then either content or a full-screen error. The compiler forces an exhaustive when, and a spinner over an error cannot be drawn.
Breaks as soon as something happens while content is on screen: pull-to-refresh, an error banner over a stale list, appending a page. Success grows isRefreshing and error fields, and each transition drops the previous data unless you copy it forward, which is why lists flash empty on refresh.
One data class
Wins for screens whose content stays put while things happen around it, which is most screens. _state.update { it.copy(...) } is one line and drops nothing by accident.
Its risk is impossible combinations the type system will not stop, such as isLoading and error both set. Keep flags few and derive what can be derived instead of storing it.
What to use in practice
Combine them: one data class per screen, with a sealed type only for the part that really has phases, such as a pagination footer. Keep it immutable and free of Android types, and expose one StateFlow rather than five that can disagree.
A SharedFlow drops an event emitted while the screen is not collecting, a state flag fires again after rotation unless the UI clears it, and a Channel keeps the event until one collector takes it. Google's guidance goes further: model the outcome as state and let the UI report it handled.
**MutableSharedFlow() with no replay**: nobody collects between ON_STOP and ON_START or during a rotation, so an event emitted then is gone. replay = 1 stops the loss but replays the last event to the recreated screen.
**A field in StateFlow<UiState>** such as navigateTo: Route?: never lost, but every new collector sees it, so a rotation navigates again unless the UI clears it.
**Channel(Channel.BUFFERED) with receiveAsFlow()**: buffers while nobody collects and hands each event to one collector, so it survives rotation and the background. Collect it on Dispatchers.Main.immediate, or a collector cancelled between receiving and handling loses the event. It suits fire-and-forget work such as the navigation after a save.
State plus an acknowledgement, from Google's UI events guide: the ViewModel sets message, the UI shows it and calls vm.messageShown(). Nothing is lost or repeated, at the cost of a callback per event; use it where a missed event is a bug, such as a declined payment.
Often the "event" is state: being signed in is a fact, and the screen navigates when state.isSignedIn turns true. SingleLiveEvent was the LiveData-era single-consumer channel.
refresh() writes back a snapshot of the state taken before repo.search suspended, so anything changed while the request was in flight, such as a filter picked during loading, is overwritten with the old value when the results arrive.
Why it happens: viewModelScope runs on the main thread, so this is not a data race, but search suspends and frees the thread. onFilterChange runs in that gap, and current.copy(...) then writes the stale filter back: a lost update.
The fix: read only what the request needs before the call, and apply the result after it with _state.update { it.copy(results = results) }, so it is merged into the latest state instead of an old copy.
**update is also atomic**: it retries a compare-and-set, so it stays correct when writers on Dispatchers.Default change the state at the same moment; _state.value = _state.value.copy(...) is not.
How to catch it: a test with a fake repository that suspends on a CompletableDeferred: call refresh(), change the filter, complete the deferred, assert the filter survived. In review, flag any _state.value read that is held across a suspend call.
A second model earns its keep only when two layers would otherwise be forced to change together; every other mapper is maintenance you pay for nothing.
Three classes exist because three owners have different requirements. UserDto is shaped by whoever runs the API: nullable everywhere, snake_case, dates as strings. UserEntity is shaped by the schema: an index, a foreign key, a syncedAt column nobody outside the data layer cares about. The domain User is shaped by the business rules: non-null, Instant, one displayName where the API had two loose fields.
When the extra class pays for itself
The API is not yours, so a field rename should break one mapper and nothing else.
The database stores columns the UI never shows, or the UI needs a value the database never stores.
The boundary is a Gradle module boundary: :core:network must not leak @Serializable types into :feature:profile.
The domain type can be stricter. Parse the nullable, unvalidated DTO once at the edge and nothing downstream writes another null check.
When one class is enough
Your own backend, no local cache, DTO and domain agreeing field for field: the mapper is rename ceremony.
A UI model is worth writing only when the screen derives something: a formatted total, an isEditable flag combining permission and status. Copying the domain class field for field is not derivation.
The trap is the opposite extreme: one class annotated @Serializable and @Entity and handed straight to a composable. Then a backend field rename silently edits your database schema, and a primary-key decision leaks into JSON. Rule of thumb: split when the two shapes have already diverged once, never in anticipation, and keep each boundary's mapping in one file so the divergence stays visible.
Code becomes untestable when it reaches out for its dependencies (singletons, Context, the real clock, a hardcoded dispatcher) instead of receiving them; constructor injection plus a few seams fixes almost all of it.
Constructor injection: no getInstance() or service locator inside business code; a test simply calls the constructor.
Wrap the non-deterministic edges: time (Clock or () -> Instant), random ids, locale, connectivity, all behind an interface or a function type.
No Android types in ViewModels and UseCases: then their tests run on the JVM in milliseconds instead of on an emulator or Robolectric.
Inject dispatchers: a hardcoded Dispatchers.IO escapes the test scheduler, and viewModelScope needs Dispatchers.setMain(testDispatcher) in a JVM test.
Fakes over mocks for your own interfaces: a fake keeps working when the implementation changes; a mock encodes today's exact calls.
Stateless content composables: CartContent(state, onAction) is tested with createComposeRule() by passing a state object, with no ViewModel at all.
The resulting mix: many JVM tests for ViewModels, UseCases and mappers, fewer Compose or Robolectric tests, and a handful of instrumented end-to-end flows.
My last app was a Compose and Hilt app with :app, :feature:* and :core:* modules, where a screen's ViewModel exposes one StateFlow over a Room-backed repository; then name one decision you would defend and one you would change.
What to cover
Context in one sentence: what the app does, team size, scale, whether it works offline. It justifies everything after it.
Layers and modules: for example Compose plus ViewModels, UseCases only where logic is shared, repositories over Retrofit and Room; :app, :feature:*, :core:*.
One feature end-to-end: "OrdersScreen collects a StateFlow<OrdersUiState>, the ViewModel observes OrderRepository.observe() from Room, a refresh writes the API response into Room, WorkManager pushes pending edits".
Cross-cutting choices: DI, navigation, error handling, how it is tested.
What makes it a senior answer
A trade-off with its reason: "we kept a domain layer only for checkout, because everywhere else UseCases were pass-throughs".
Something that went wrong and what you changed afterwards.
Honest ownership: "we" for team decisions, "I" for what you built.
Avoid reciting libraries, saying "Clean Architecture" without a why, and drawing more layers than the app had. Every part you name invites a follow-up, so name only what you can explain.
Combine the sources into the single state object the screen renders, then share the result with stateIn so a rotation does not restart every query underneath.
The building blocks
combine(a, b, c) { ... } produces the state from the latest value of each source. It emits nothing until every source has emitted at least once, so a source that may stay silent needs a default or an onStart emission; a screen stuck on its initial state is almost always this.
flatMapLatest when one flow decides which other flow to observe, such as a selected id driving a details query. It cancels the previous inner flow, which is exactly what you want.
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial) turns the cold chain into hot shared state: one upstream collection regardless of how many collectors, kept alive briefly after the last one goes away.
That timeout is what interviewers push on: WhileSubscribed(5_000) keeps the upstream alive across a configuration change, since the UI resubscribes within a frame or two, yet stops it when the user really leaves.
The pitfalls that show experience
The combine lambda re-runs on every emission from any source, so it must be a cheap pure projection. Suspending work goes upstream.
An exception in any source terminates the stateIn coroutine and, with no handler, crashes the app. Wrap each source in catch and map the failure into a field of the state.
StateFlow deduplicates by equality, so the state class needs correct equals; a data class over immutable fields has it, a mutable list inside one does not.
The alternative worth mentioning: collectors launched in init that each call _state.update { } on one MutableStateFlow. It is easier to debug and lets one section fail alone; combine is more declarative. Either is defensible; exposing five separate flows to the UI is not.
state is a custom getter, so every read builds a new stateIn chain that starts at isLoading = true and runs the query again; the screen reads it on every recomposition, so each list that arrives brings back a fresh, loading flow.
Why it loops: collectAsStateWithLifecycle() is keyed on the flow instance. A new instance restarts collection and shows the spinner, the list arrives, the screen recomposes, reads vm.state again and gets yet another new flow.
It also piles up work: each abandoned chain keeps its upstream query alive for another 5 seconds and its sharing coroutine in viewModelScope until onCleared(); with SharingStarted.Eagerly the queries would never stop.
The fix: = instead of get() =: the chain is built once with the ViewModel, every reader gets the same StateFlow, and a rotation re-subscribes inside the 5-second window instead of starting a new query.
Same trap, other shapes: fun state() = ... .stateIn(...) or a shareIn inside a function the UI calls. A hot flow must be a property created once.
How to catch it: a unit test asserting vm.state === vm.state, or a fake repository that counts subscriptions. In review, treat get() = followed by stateIn or shareIn as a red flag.
Errors have to be translated at the same boundary the data is: the repository turns transport failures into a domain type the UI can handle exhaustively, and whatever it uses to catch them must let CancellationException through untouched.
Map at the boundary
An HttpException, an IOException or a SerializationException reaching a composable means the UI now depends on Retrofit. Two things go wrong at once: the screen can only render e.message, which is why the user is reading a status code, and every screen invents its own catch. The repository is where you know that a 401 means "signed out", a 422 means "the form is wrong" and an IOException means "offline", so return a sealed interface result and let the ViewModel when over it with no else branch.
The runCatching trap
runCatching and a bare catch (e: Exception) both swallow CancellationException, because in coroutines cancellation is an exception. Swallow it and the coroutine keeps running after its scope was cancelled, and the cancellation itself lands in your error branch, so the user who just navigated away gets a failure snackbar. Rethrow first with catch (e: CancellationException) { throw e }, or catch narrowly (IOException, HttpException).
One more boundary rule: do not model expected outcomes as exceptions. "Wrong password" is a value the repository returns, not something it throws; exceptions are for the transport layer, results are for the domain. The rule of thumb is that no type from retrofit2, okhttp3 or android.database should be nameable above the data layer.
One thin :app, one module per feature and shared :core:* modules below them; features depend on core and never on each other, and only :app sees everything. That keeps builds parallel and lets the compiler enforce the boundaries.
Module types
:app: the Application, the top-level NavHost, DI assembly.
:feature:*: UI, ViewModels and state for one flow.
:core:* (domain, data, network, database, designsystem, testing): shared code with a narrow public API, the rest internal.
Dependency direction
Feature to core, never feature to feature. Two features that need each other form a cycle Gradle refuses to build, and it means the boundary is wrong.
Break it by depending on something both can see: a callback wired in :app, a small :core:navigation module of routes, or move the shared model into core.
DI crosses modules through Hilt: each module contributes @InstallIn(SingletonComponent::class) bindings, and the graph is generated in :app, the only module that sees every implementation.
Gains and costs
Parallel compilation, smaller rebuilds when a change stays behind an implementation dependency, per-module tests, clear ownership per team.
Costs: configuration time, boilerplate, and no speed-up if every feature depends on a fat :core:common that changes daily. Share build logic through convention plugins in build-logic and versions through libs.versions.toml.
Migrate screen by screen from the leaves inwards, adding a ViewModel first and Compose second, so that every commit is shippable.
The order
Put the tooling in place with nothing user-visible: Kotlin and coroutines, a DI graph, the Compose compiler plugin with interop dependencies.
Move logic out of the Fragments into ViewModels while keeping the XML. This is where most of the value is (testable classes and state that survives rotation), and it is entirely independent of Compose.
Draw a data-layer boundary: a repository interface per data type with the existing code hidden behind it, so a screen can later be rewritten without touching networking.
Convert leaf UI first (a list row, an empty state, a dialog), hosted by a ComposeView inside the existing layout. Low risk, and it forces the design-system decision early, while it is still cheap.
Convert whole screens, still hosted by a Fragment, so navigation is untouched. AndroidView wraps the widget you cannot replace yet, such as a map or a video surface.
Only once most screens are Compose, replace the navigation layer and collapse toward a single Activity.
Interop details that bite
A ComposeView in a Fragment needs setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) so the composition lives exactly as long as the Fragment's view. The default disposes on window detach, which can fire early (for example in ViewPager2) and drop state.
A Compose theme has to mirror the XML one so the two halves look identical during the transition.
A ViewModel shared between an XML parent and a Compose child works because both resolve to the same store owner.
Keeping it safe
Resist a big-bang rewrite branch that never merges, starting with the hardest screen to prove it can be done, and rewriting logic and UI in the same commit, which makes every regression ambiguous.
Make new screens Compose-only by rule, track a visible metric such as the share of screens migrated, write characterization tests around logic before moving it, and put a per-screen flag on anything risky enough to need a rollback.