← All problems

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.

Free~26 min

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.

Rendering diagram…
LayerContentsAndroid imports?
PresentationViewModels, Compose / View UI, UiState typesYes
DomainUse cases, repository interfaces, domain modelsNo - pure Kotlin
DataRepository implementations, Room, Retrofit, WorkManagerYes
Core / CommonDI modules, database setup, network setup, utilsYes

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(...) : PostRepository

Domain 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.

Rendering diagram…
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 so Loading + posts.isNotEmpty is 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 → ViewModel stateIn updates → 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.

Rendering diagram…
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 _state directly.
  • ClearAll is SearchState() - one line. In MVVM with 8 MutableStateFlows, "reset everything" is 8 assignments and easy to miss one.
  • reduce is 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

DimensionMVVMMVI
User eventDirect method callSealed Intent dispatched
State mutationAny method can mutate _uiStateOnly reduce() can
Impossible statesSealed UiState prevents mostStructurally impossible
Reset all stateMust remember every fieldSearchState() - one line
TestingMock UseCases, assert StateFlowPure: reduce(state, intent)
BoilerplateLowHigh - Intent class per action
Action replayNoYes - Intents are a log
Paging 3 fitNaturalAwkward - PagingData is a stream, not a value
Rendering diagram…

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
}
Rendering diagram…

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 externalScope injected into the Repository. The named idiom is externalScope.async { ... }.await() inside a suspend function: if the caller's coroutine is cancelled (user backgrounded the screen mid-write), await cancels 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 ViewModel would 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 from viewModelScope without worrying about which thread.
  • Composes with other Use Cases. A RefreshFeedAndAuthorsUseCase can depend on RefreshFeedUseCase and RefreshAuthorsUseCase. 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. The Stream suffix signals "reactive, not a one-shot."
  • XRepository for the public interface; OfflineFirstXRepository, InMemoryXRepository, DefaultXRepository for implementations. The prefix names the design choice in one word.
  • XEntity for Room, NetworkX for the API DTO, plain X for the domain model. The mapper-per-direction convention is in Persistence.
  • Verb + Noun + UseCase for use cases. FakeXRepository (not MockX) 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, flatMapLatest are 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:

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.log is 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; EnqueueUploadUseCase is 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.