CASE STUDY

Android MVVM Architecture for API-Driven Screens

3 min read·525 words·Intermediate

Asked at

1 candidate report in May 2026

How to use this case study

SDE-2 / Mid

Explain the layers (View, ViewModel, Repository, data sources), how a screen loads data from an API, and how UI state is represented.

SDE-3 / Senior

Go deeper on coroutines/Flow, error handling and retries, offline cache with Room (single source of truth), configuration changes and pagination.

Staff / Principal

Discuss modularization, dependency injection, testing strategy, and API contract evolution between app versions.


0) Problem Restatement

Design the client-side architecture of an Android app screen that calls backend APIs, using MVVM (Model–View–ViewModel), as asked at OpenAI for a mobile role. For example: a list of conversations that loads from an API, supports pull-to-refresh, pagination and offline viewing, and survives screen rotation. Cover the HTTP request lifecycle, error handling, caching and testability.


1) The Layers

Architecture Diagram

flowchart LR
    V["View - Activity / Compose UI"] -->|"user events"| VM["ViewModel - holds UiState"]
    VM -->|"StateFlow of UiState"| V
    VM --> UC["Use cases (optional)"]
    UC --> R["Repository - single source of truth"]
    R --> LOCAL[("Local DB - Room")]
    R --> REMOTE["Remote API - Retrofit / OkHttp"]
    REMOTE --> NET["Backend"]
  • View (Jetpack Compose or Activity/Fragment): draws the UI from state and sends user events (tap, refresh) to the ViewModel. No business logic.
  • ViewModel: holds the screen's UiState, survives configuration changes (rotation), runs work in viewModelScope, and exposes StateFlow<UiState>.
  • Repository: decides where data comes from (cache or network), and hides Retrofit and Room from the ViewModel.
  • Data sources: Retrofit API (remote) and Room database (local).
  • Dependency injection (Hilt) wires these together, which makes testing easy.


2) UI State

data class ConversationsUiState(
    val items: List<ConversationUi> = emptyList(),
    val isLoading: Boolean = false,
    val isRefreshing: Boolean = false,
    val error: String? = null,        // user-friendly message
    val endReached: Boolean = false
)

One immutable state object per screen. The View just renders it. This avoids bugs where the loading spinner and error message disagree.


3) Request Lifecycle

class ConversationsViewModel @Inject constructor(private val repo: ConversationRepository) : ViewModel() {
    private val _state = MutableStateFlow(ConversationsUiState(isLoading = true))
    val state: StateFlow<ConversationsUiState> = _state

    init {
        viewModelScope.launch {
            repo.observeConversations()            // Room Flow: emits whenever the DB changes
                .collect { list -> _state.update { it.copy(items = list.map(::toUi), isLoading = false) } }
        }
        refresh()
    }

    fun refresh() = viewModelScope.launch {
        _state.update { it.copy(isRefreshing = true, error = null) }
        when (val r = repo.refresh()) {            // network -> write to Room
            is Result.Error -> _state.update { it.copy(isRefreshing = false, error = r.userMessage) }
            is Result.Success -> _state.update { it.copy(isRefreshing = false) }
        }
    }
}
  • Single source of truth: the UI always reads from Room, and the network only writes into Room. So offline mode works automatically, and there's never a mismatch between the cache and the screen.
  • Cancellation: viewModelScope cancels requests when the screen is closed. No leaks, no wasted calls.
  • Threading: Retrofit suspend functions and Room run off the main thread, and state updates are collected on the main thread.


4) Networking Details

  • Retrofit + OkHttp: an interceptor adds the auth token, and an authenticator refreshes expired tokens once and retries.
  • Timeouts: connect/read timeouts (e.g., 10s/30s). For streaming responses (LLM tokens), use a streaming call and update the state incrementally.
  • Retries: automatic retry with backoff only for idempotent GETs and network errors. Never auto-retry non-idempotent POSTs without an idempotency key.
  • Error mapping: convert HTTP and IO errors into a Result.Error with a user-friendly message (no connection, session expired, server error) in the repository, so the ViewModel stays simple.
  • HTTP caching: ETags / If-None-Match to save data on refresh.


5) Pagination and Offline

  • Use Paging 3 with a RemoteMediator: it loads pages from the API into Room, and the UI pages from Room. Scrolling works offline for cached pages.
  • Queue user actions made offline (e.g., "archive conversation") with WorkManager, and retry when back online.


6) Testing

  • ViewModel tests: fake the repository, and assert UiState transitions (loading → items; refresh error → error message).
  • Repository tests: MockWebServer for the API, an in-memory Room DB.
  • UI tests: Compose testing with fake state.


7) Wrap-Up

Keep the View dumb, give each screen a ViewModel that exposes a single immutable UiState through StateFlow and launches work in viewModelScope, and put a repository in front of Retrofit and Room with Room as the single source of truth. Handle auth, timeouts, safe retries and error mapping in the data layer, use Paging 3 with RemoteMediator for pagination and offline, and inject dependencies with Hilt so every layer is testable in isolation.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →