Mobile Core Concepts
Architecture
Clean Architecture layers, MVVM vs MVI, the Repository observe/refresh split, the three types of data operations, and Use Cases - the architecture vocabulary every mobile system-design panel listens for.
Android architecture interview questions are really one question: "show me you can write a non-trivial app whose business logic survives a UI rewrite, whose data layer survives an API change, and whose state machine doesn't go quietly impossible." This page covers the canonical answers, layered from principles down to mechanics: the four named principles every panel listens for (SoC, drive UI from data, SSOT, UDF); Clean Architecture as the layer model; MVVM vs MVI as the UI-state pattern; the Repository observe/refresh split as the data-layer contract; the three types of data operations as the cancellation/durability classification; and Use Cases as the seam between data and presentation. The Google "Guide to app architecture" is the de-facto reference; the patterns here are the version interviewers expect.
Architectural principles
Four named principles every panel listens for. Naming them explicitly is the first thing a candidate does that signals architecture vocabulary, not just framework familiarity.
Separation of concerns. Each layer / module / class has one clearly-bounded responsibility. The smell that fails this: code accumulating in Activity (or any single file). Panel-grade phrasing: "the Activity hosts the UI; everything else lives in classes that don't depend on the Activity's lifecycle." The reason it matters on Android specifically: the OS controls Activity lifetime aggressively (rotation destroys and recreates; background kill drops it entirely) - state and logic anchored to that lifetime are lost on every external event.
Drive the UI from data models. Persistent ones where possible. Data models are independent of the UI components and the Android lifecycle - a Post doesn't disappear when the OS recreates the screen for orientation. The reverse - "the UI is the source of truth and we persist what's on screen" - collapses on every config change and on every process death.
Single Source of Truth (SSOT). Every piece of state has exactly one owner; everyone else sees an immutable view. Mutations centralize, so bugs become traceable. Covered in depth in State management; named here so the rest of this page can refer to it.
Unidirectional Data Flow (UDF). State flows down, events flow up. The corollary of SSOT - if mutations centralize, the propagation direction can't be bidirectional. Also covered in State management.
The four principles together explain the rest of this page: layers exist to enforce SoC; the Repository pattern exists because the data layer has to be the source of truth for every piece of data; the ViewModel-and-StateFlow shape exists to enforce UDF; the domain layer exists when SoC inside the presentation layer alone isn't tight enough.
Clean Architecture layers
Three layers, plus a shared core. Dependencies point inward - outer layers depend on inner, inner layers know nothing about outer.
| Layer | Contents | Android imports? |
|---|---|---|
| Presentation | ViewModels, Compose / View UI, UiState types | Yes |
| Domain | Use cases, repository interfaces, domain models | No - pure Kotlin |
| Data | Repository implementations, Room, Retrofit, WorkManager | Yes |
| Core / Common | DI modules, database setup, network setup, utils | Yes |
The payoff is the pure-Kotlin Domain layer. Business logic - "a PostDetail is a Post combined with its Comments; a comment can be optimistically attached before server-ack" - lives where it can be tested with plain JUnit and no emulator.
Dependency inversion at the data boundary:
// Domain (pure Kotlin):
interface PostRepository {
fun observePosts(): Flow<List<Post>>
suspend fun refresh()
}
// Data:
class PostRepositoryImpl @Inject constructor(...) : PostRepositoryDomain never imports PostRepositoryImpl. The DI graph (covered in the Dependency Injection Core Concept) wires the implementation at runtime.
Domain models are not Room entities. Entities have @Entity, @PrimaryKey, table-shape constraints; domain models are plain data classes with the shape the business logic wants. Mappers convert at the data/domain boundary. Domain never sees Room annotations or Retrofit response DTOs.
Sources: Guide to app architecture · Now in Android
MVVM
The default UI-state pattern. UI calls ViewModel methods directly; ViewModel exposes one StateFlow<UiState> per screen; a sealed UiState interface prevents impossible state combinations.
sealed interface FeedUiState {
data object Loading : FeedUiState
data class Success(val posts: List<Post>) : FeedUiState
data class Error(val message: String) : FeedUiState
}
@HiltViewModel
class FeedViewModel @Inject constructor(
private val getFeed: GetFeedUseCase,
private val toggleLike: ToggleLikeUseCase,
) : ViewModel() {
val uiState: StateFlow<FeedUiState> = getFeed()
.map { FeedUiState.Success(it) }
.stateIn(viewModelScope, WhileSubscribed(5_000), FeedUiState.Loading)
fun onLike(postId: String) {
viewModelScope.launch { toggleLike(postId) }
}
}The MVVM contract that interviewers test:
- UI calls ViewModel methods directly - no sealed wrapper, no event bus.
- One
StateFlow<UiState>per screen, sealed soLoading + posts.isNotEmptyis unrepresentable. - ViewModel imports Use Cases, not Repositories. The Use Case is the bridge.
- Data flows up automatically via
Flow- no manual "notify UI". Room change → DAO Flow emits → repo Flow emits → ViewModelstateInupdates → UI recomposes.
Update related UiState fields in one copy(), never one at a time. Two _state.update { it.copy(loading = false) } and _state.update { it.copy(posts = newPosts) } calls from different coroutines can interleave into an inconsistent intermediate state; collapse them into a single update { it.copy(loading = false, posts = newPosts) }.
MVI
Every user action is a sealed Intent. All state mutations flow through a pure reduce(state, intent) → state. State is replaced, never mutated in place. Side effects (navigation, snackbar) classically live on a separate SharedFlow<Effect> - not in State - though current Android-team guidance prefers events embedded in state with a consumed marker; both shapes are discussed in State management.
sealed interface SearchIntent {
data class QueryChanged(val q: String) : SearchIntent
data class FilterToggled(val f: Filter) : SearchIntent
data object ClearAll : SearchIntent
}
data class SearchState(
val query: String = "",
val filters: Set<Filter> = emptySet(),
val results: List<Result> = emptyList(),
val phase: Phase = Phase.Idle,
)
@HiltViewModel
class SearchViewModel @Inject constructor(...) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
fun dispatch(intent: SearchIntent) {
viewModelScope.launch { _state.update { reduce(it, intent) } }
}
private fun reduce(state: SearchState, intent: SearchIntent): SearchState =
when (intent) {
is QueryChanged -> state.copy(query = intent.q, phase = Phase.Searching)
is FilterToggled -> state.copy(filters = state.filters + intent.f)
ClearAll -> SearchState() // reset in one line
}
}The MVI invariants that earn it the boilerplate:
- One
reduce()is the only code that produces a new state. No method can mutate_statedirectly. ClearAllisSearchState()- one line. In MVVM with 8MutableStateFlows, "reset everything" is 8 assignments and easy to miss one.reduceis a pure function:reduce(state, intent) == expectedState. Zero mocks, no coroutine machinery in unit tests.- Intents are a log - replay the exact sequence to reproduce a bug.
When to use which
| Dimension | MVVM | MVI |
|---|---|---|
| User event | Direct method call | Sealed Intent dispatched |
| State mutation | Any method can mutate _uiState | Only reduce() can |
| Impossible states | Sealed UiState prevents most | Structurally impossible |
| Reset all state | Must remember every field | SearchState() - one line |
| Testing | Mock UseCases, assert StateFlow | Pure: reduce(state, intent) |
| Boilerplate | Low | High - Intent class per action |
| Action replay | No | Yes - Intents are a log |
| Paging 3 fit | Natural | Awkward - PagingData is a stream, not a value |
The default is MVVM with a sealed UiState. It captures most of MVI's safety - "loading and posts can't both be present" - without the boilerplate. Reach for MVI when you have a screen with many concurrent actions (search with query + filters + sort + pagination + save + clear), a large team where one enforced mutation path matters, or bugs that are hard to reproduce and you'd benefit from an intent log.
Paging 3 is the one place MVI fits awkwardly: PagingData is a stream from the Pager, not a value the reducer can hold in State. For paging-heavy screens, MVVM is the right pick regardless.
Repository: observe vs refresh
The data-layer contract that makes everything else work. Two distinct responsibilities, never mixed:
interface StockRepository {
fun observeQuotes(symbols: List<String>): Flow<List<StockQuote>> // passive
suspend fun refresh(symbols: List<String>) // active
}The key invariant: refresh() never returns data. It writes to Room (or whatever the local store is) and lets observe do the rest. The ViewModel never reads refresh's return value to compose UI state directly.
Why this split is the right shape:
- Offline works for free. Room is the source of truth; the UI doesn't know whether the data came from the network or disk.
- Process death works for free. ViewModel comes back, re-subscribes to
observeQuotes, gets the last cached data immediately. - Rotation never triggers a re-fetch. The Flow is observed via
stateIn(viewModelScope, WhileSubscribed(5_000), ...); rotation passes through the 5-second window without re-firing the upstream. - Pull-to-refresh and background sync use the same code path. Both call
refresh(). UI doesn't care who pulled the trigger.
Types of data operations
Every data-layer operation falls into one of three categories along a single axis: how long does it live, and what cancels it? The classification a panel will use to test scoping decisions:
- UI-oriented operations. Tied to the screen's lifetime. Cancelled when the user navigates away. Run in
viewModelScope. Examples: "fetch this post's details," "search-as-you-type." - App-oriented operations. Tied to the app process. Cancelled only on process death. Run in a long-lived
externalScopeinjected into the Repository. The named idiom isexternalScope.async { ... }.await()inside asuspendfunction: if the caller's coroutine is cancelled (user backgrounded the screen mid-write),awaitcancels but the inner work continues, the cache gets populated, and the next visit sees the result. - Business-oriented operations. Must outlive the process. "This upload must finish." Run in WorkManager. The Repository enqueues a
WorkRequest; the Worker drains it across reboots, network outages, app updates. See Background work and scheduling for the mechanics.
The decision a panel will run: "the user kicks this off, then closes the app - what happens?" Each answer maps to a category, each category to a different scope and a different durability. A photo upload is business-oriented; a search-as-you-type is UI-oriented; a cache fill that should populate even if the screen closes is app-oriented. Misclassifying turns up later as a complaint - "uploads stop when I leave the app", "the cache never warms up", or worse, a battery-drain bug from work that should have stopped but didn't.
The downstream pattern this enables: an offline-first design (see Offline-first and sync) routes every write through one of these three categories explicitly - online-only is UI-oriented, queued is business-oriented, lazy is a hybrid (app-oriented cache write plus a business-oriented sync).
Domain layer and Use Cases
The domain layer is optional. Adding it when an app doesn't need it is over-engineering; skipping it when an app does is the technical debt that turns ViewModels into 2,000-line dumping grounds. The decision framework, in panel-grade phrasing:
- The same business logic is reused by multiple ViewModels.
- The business logic is complex enough that testing it through a
ViewModelwould mock half the world. - A ViewModel is becoming a dumping ground for cross-repository orchestration.
If none of those is true, the layer hasn't earned its weight - the ViewModel can call the Repository directly. Reintroduce the Use Case when the second ViewModel needs the same combine-and-map.
A Use Case is a single-responsibility class with a single public method. The named convention: Verb + Noun + UseCase (GetLatestNewsUseCase, MakePaymentUseCase, FormatDateUseCase). The class is callable as a function via operator fun invoke so call sites read like function calls:
class GetPostDetailUseCase @Inject constructor(
private val posts: PostRepository,
) {
operator fun invoke(postId: String): Flow<PostDetail> =
combine(
posts.observePost(postId),
posts.observeComments(postId),
) { post, comments -> PostDetail(post, comments) }
}Three core rules:
- No own lifecycle - scoped to the caller. A Use Case is constructed fresh per ViewModel that uses it; it doesn't survive anything the caller doesn't. Stateless by design.
- Main-safe. Either delegate threading to the repositories (the usual case - DAOs and Retrofit suspend functions are already main-safe), or wrap the work in
withContext(defaultDispatcher). The ViewModel calls the Use Case fromviewModelScopewithout worrying about which thread. - Composes with other Use Cases. A
RefreshFeedAndAuthorsUseCasecan depend onRefreshFeedUseCaseandRefreshAuthorsUseCase. Multi-level domain layers are normal in larger apps.
The most common smell - the one-line passthrough Use Case:
class GetUserUseCase @Inject constructor(private val users: UserRepository) {
operator fun invoke() = users.getUser() // adds zero value
}If a Use Case is a one-line passthrough to a Repository call, delete it. The ViewModel can call the Repository directly. Use Cases earn their boilerplate when they hold logic, not when they hold a method reference.
Trade-off: data layer access restriction. Some teams enforce "ViewModels never touch the data layer directly; everything goes through a Use Case." Pro: every cross-cutting concern (logging, analytics on data access) has one place to live. Con: every passthrough becomes a one-line Use Case, and the layer becomes ceremony. The panel-grade answer: add Use Cases where they hold logic, not where they hold a method reference; ViewModels talk to the Repository directly when the call is a passthrough.
Naming conventions
The vocabulary an interview panel listens for, all at the data and domain layers:
getXStream(): Flow<X>for observable reads. TheStreamsuffix signals "reactive, not a one-shot."XRepositoryfor the public interface;OfflineFirstXRepository,InMemoryXRepository,DefaultXRepositoryfor implementations. The prefix names the design choice in one word.XEntityfor Room,NetworkXfor the API DTO, plainXfor the domain model. The mapper-per-direction convention is in Persistence.Verb + Noun + UseCasefor use cases.FakeXRepository(notMockX) for substitute implementations used in tests.
These cost nothing in the IDE and pay back in interview-time clarity - a candidate who reaches for OfflineFirstFeedRepository tells the panel in two words what the design is.
See also
- Concurrency -
viewModelScope,stateIn(WhileSubscribed(5_000)),Flow.combine,flatMapLatestare the substrate. - State management - SSOT and UDF as named principles, the two-kind state-holder split (ViewModel vs plain class), one-shot events as state, plus the survival ladder.
- Dependency injection - Hilt is how Clean Architecture's dependency inversion actually wires up at runtime.
- Persistence - Repository implementations build on Room or DataStore; the observe/refresh contract assumes a reactive local store; the model-per-layer pattern lives there.
- Offline-first and sync - the data-layer design playbook one level above this page: local DB as SSOT, three write strategies, pull vs push sync, conflict resolution.
Further reading on developer.android.com:
- Guide to app architecture
- Architectural principles intro
- Domain layer
- Data layer
- Architecture recommendations
Used in
- Crash Reporter SDK - the breadcrumb buffer is a Repository-shaped abstraction internally (one writer thread, one snapshot reader); the public
Reporter.setKey/Reporter.logis the analog of "Use Case" - operations that hide the internal storage details. - Photo Gallery App -
MediaRepository(observe device + cloud library) +UploadRepository(refresh / enqueue) is the canonical observe/refresh split;EnqueueUploadUseCaseis the bridge. - Messenger App - the chat screen's state machine is the canonical "8+ user actions" case where MVI earns its keep (typing / sending / scrolling / mark-as-read / scroll-to-bottom / load-history / connection-lost overlay).
- Newsfeed App - feed screen is a MVVM-with-Paging-3 case; the Paging stream doesn't fit MVI's reducer, but the cell-level interactions (like, reply) stay declarative.
- Doc Editor App - CRDT operational-transform queue lives in a Repository implementation; ViewModels never see operations directly, only the resulting document state.
Done reading? Mark it so it sticks in your dashboard.