Mobile System Design Explained

ADVANCED › System Design

A mobile system design interview isn't asking you to ship a finished app in 45 minutes, it's asking whether you can **think like an engineer under ambiguity**: scope the problem, pick sensible layers, and defend trade-offs out loud. Weak candidates jump straight to code or a database schema. Strong candidates work a repeatable framework:

1. **Requirements and scope** (functional + non-functional) 2. **Layers and single source of truth** 3. **Offline and caching strategy** 4. **Sync and conflict resolution** 5. **Pagination and API shape** 6. **Resilience, state modeling and testing**

This lesson walks each step. Two follow-up lessons apply the same framework to concrete prompts, an offline-first feed and a real-time chat app. Learn the six steps so you never freeze when a prompt is deliberately vague.

**Layers and the single source of truth (SSOT).** Android's recommended architecture has three layers: a **UI layer** (Compose + a ViewModel state holder), an optional **Domain layer** (use cases for logic shared across screens), and a **Data layer** (repositories plus single-responsibility data sources, for example a Room DAO and a Retrofit API).

The SSOT rule: exactly one owner per piece of data, exposing it as an immutable type. For an offline-capable feature that owner is almost always the local database, not the network and not the ViewModel. The ViewModel just holds UI state derived from what the repository exposes:

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(FeedUiState())
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
}

Exposing a read-only StateFlow keeps the ViewModel the sole writer, so the UI can only read state and send events upward.

**Unidirectional data flow (UDF)** is how state and events move once you've picked layers. State flows one way, down: from the SSOT (repository or database) into the ViewModel's StateFlow, into the UI. Events flow the other way, up: a button tap becomes a function call on the ViewModel, which is the only thing allowed to mutate the SSOT.

class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    fun onRefresh() {                     // event flows UP
        viewModelScope.launch { repo.refresh() }
    }
}

// Compose: state flows DOWN, events sent up
val state by viewModel.uiState.collectAsStateWithLifecycle()
Button(onClick = { viewModel.onRefresh() }) { Text("Refresh") }

Why interviewers care: UDF is what makes a design testable and debuggable. If a bug shows the wrong data, there's exactly one place a write could have happened.

**Offline and caching strategy** is usually the pivotal decision in the whole interview, because it sets your data flow for everything after it. Three common strategies:

- **Cache-first (offline-first):** serve cached data immediately, hit the network only on a miss or explicit refresh. Best when availability and instant response matter more than absolute freshness (a news feed, a photo gallery). - **Network-first:** always try the network, fall back to cache on failure. Best when freshness is critical and staleness is actively wrong (a live stock price). - **Stale-while-revalidate (SWR):** show cached data instantly, then silently refetch in the background and update the UI when fresh data lands. Best when you want both instant paint and eventual freshness (most feeds).

Say the trade-off out loud: cache-first optimizes for offline availability, network-first optimizes for correctness, SWR is the common middle ground.

**Pagination** for an infinite, changing list has three common shapes:

- **Offset or limit** (?page=3&size=20): simple, but suffers page drift, insertions or deletions shift every row's offset, so you skip or repeat items, and large offsets get slow to query. - **Keyset or seek** (WHERE id > :lastId ORDER BY id LIMIT 20): indexed and fast, but leaks the ordering column to the client. - **Cursor or token** (?cursor=eyJpZCI6MTIzfQ): an opaque, server-issued token. Drift-resistant like keyset, but hides the DB internals behind an opaque string the client just echoes back.

For an infinite, frequently-updated feed, cursor pagination is usually the right call: it survives concurrent writes without duplicate or missing rows, and it doesn't commit your API contract to a specific column.

**Modeling state explicitly** stops loading or empty or error bugs before they happen. Instead of independent booleans (isLoading, isError, isEmpty) that can drift into impossible combinations, expose one closed type and let when force you to handle every case:

sealed interface HomeUiState {
    data object Loading : HomeUiState
    data object Empty : HomeUiState
    data class Success(val items: List<Item>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}

A StateFlow<HomeUiState> from the ViewModel makes states mutually exclusive by construction, you literally cannot be both Loading and Error at once. Distinguish Empty (a genuinely empty result) from Loading (still fetching): they can look similar on screen but need different UI and different logic.

**Resilience**: a real API returns more than 200s. Model failure explicitly and respond appropriately:

- **429 (rate limited):** retry with exponential backoff plus jitter, and honor a Retry-After header if present. Never retry in a tight loop, that makes the throttling worse. - **401 (unauthorized):** refresh the auth token once and retry; if that also fails, force a re-login rather than retrying forever. - **5xx or network errors:** retry a bounded number of times, then surface an Error state with a retry action, don't fail silently.

suspend fun <T> retryWithBackoff(maxRetries: Int = 4, block: suspend () -> T): T {
    repeat(maxRetries) { attempt ->
        try { return block() } catch (e: HttpException) {
            if (e.code() != 429) throw e
            delay(2.0.pow(attempt).toLong() * 1000 + Random.nextLong(500))
        }
    }
    return block()
}

Saying "I'd back off exponentially and cap retries" out loud is exactly the kind of detail that separates a pass from a borderline.

**Testing strategy** closes out most interviews and shows you thought about maintainability, not just the happy path:

- Unit-test ViewModels and repositories with hand-written **fakes**, Android favors fakes over mocks because they behave like the real thing and produce less brittle tests. - Use **Turbine** to assert Flow or StateFlow emissions in order. - An **in-memory Room** database for DAO tests, a **MockWebServer** for the API layer. - Compose UI or instrumented tests for the screens themselves.

Pulling the whole framework together: scope the problem, pick layers with one SSOT, choose an offline strategy that matches the freshness requirement, pick pagination that survives concurrent writes, model state as a closed set, handle failure modes explicitly, and name your test strategy per layer. That's a complete answer to almost any mobile system design prompt, the next two lessons apply it end to end.

Back to Mobile System Design