Mobile System Design Explained

ADVANCED › System Design

A mobile system design interview is not asking you to ship a finished app in forty five minutes. It is 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 a database schema or start typing Compose code. Strong candidates work a repeatable framework instead: gather requirements and set scope, choose layers and a single source of truth, pick an offline and caching strategy, decide how pagination and the API should shape, model state and failure explicitly, and name a testing strategy per layer.

Requirements come first because everything downstream depends on them. Functional requirements are what the feature must do: can a user read articles offline, does chat need to survive an app kill, does the photo gallery need infinite scroll. Non-functional requirements are the constraints: how flaky is the network, how much local storage is acceptable, does battery life matter more than freshness. An interviewer deliberately gives you a vague prompt to see whether you ask questions and narrow scope before committing to an architecture. Committing early to a schema or a library before you've scoped the problem is one of the most common ways candidates lose points in the first five minutes.

Android's recommended architecture splits a feature into three layers. The UI layer is Compose plus a ViewModel that holds UI state; it renders whatever state it is given and forwards user actions. The data layer is repositories plus single responsibility data sources, for example a Room DAO for the local database and a Retrofit service for the network; the repository is the only thing that talks to both. Between them sits an optional domain layer of use cases.

You reach for a use case when business logic is genuinely reused across screens or complex enough that leaving it inline would duplicate it in every ViewModel that needs it. A use case takes one or more repositories as input, does the filtering, sorting, or combining, and hands a plain result back to the ViewModel. It does not replace the repository, and it is never the single source of truth, it is a thin, testable slice of logic sitting between the ViewModel and the data layer. If a feature is simple, most teams skip the domain layer entirely and call the repository straight from the ViewModel.

class GetSortedArticlesUseCase(private val repo: ArticleRepository) {
    suspend operator fun invoke(filter: Filter): List<Article> =
        repo.getArticles()
            .filter { it.matches(filter) }
            .sortedByDescending { it.publishedAt }
}

Single source of truth means exactly one owner for each piece of data. That owner exposes the data as an immutable type, and it is the only thing allowed to mutate it; everyone else requests changes through a function call or an event instead of writing to the data directly. This matters because without one owner, two parts of the app can hold conflicting copies of the same data, and the bug that results is nearly impossible to trace back to a specific write.

For an offline-first feature, the SSOT is almost always the local database, not the network and not the ViewModel. Room owns the on-device copy of the data. The repository's job is to read from Room and push network updates into Room, never to hand raw network responses straight to the UI. The ViewModel doesn't own data either, it just observes what the repository exposes and turns it into UI state. This is why an offline-first screen keeps working with the network dead: the UI was never actually watching the network, it was watching the database the whole time.

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun observeAll(): Flow<List<Article>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertAll(articles: List<Article>)
}

fun getArticles(): Flow<List<Article>> = dao.observeAll().also {
    scope.launch { dao.upsertAll(api.fetchLatest()) }
}

Unidirectional data flow is the traffic law that keeps a single source of truth from turning into chaos. State flows one way: down, from the SSOT into the ViewModel's state holder, into the UI that renders it. Events flow the other way: up, from a button tap or a swipe in the UI into a function call on the ViewModel, which is the only thing allowed to actually mutate the SSOT.

The payoff is debuggability. If a screen is ever showing the wrong data, there is exactly one place a write could have happened, the function on the ViewModel that owns that piece of state. Compare that to a design where the UI can poke the database directly: now a bug could have been written from anywhere, and you're stuck adding breakpoints all over the codebase to find it. UDF is also what makes state predictable enough to unit test, you can call the event function and assert on the resulting state without needing a running UI at all.

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

    fun onRefresh() {                        // event flows UP
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            _uiState.update { it.copy(isLoading = false, items = repo.getItems()) }
        }
    }
}

UDF only holds if the ViewModel is genuinely the sole writer of its own state, and that has to be enforced by the types, not just by convention. If a ViewModel exposes a public MutableStateFlow, nothing stops a Composable from setting its value directly, and the moment that happens you've quietly broken unidirectional data flow, the UI is now a second writer.

The fix is a private MutableStateFlow backing a public, read-only StateFlow. The ViewModel keeps the mutable reference to itself and only ever exposes the immutable view. Callers can collect it, they cannot assign to it. This isn't about raw performance, a MutableStateFlow read is not meaningfully slower, it's about encapsulation: making the illegal state of 'the UI wrote to the SSOT' something the compiler rejects rather than something a code reviewer has to catch by eye.

class FeedViewModel : ViewModel() {
    // BAD: UI could call _uiState.value = …, breaks UDF
    // val uiState = MutableStateFlow(FeedUiState())

    // GOOD: ViewModel is sole writer; UI gets a read-only view
    private val _uiState = MutableStateFlow(FeedUiState())
    val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
}

Once you know Room is the SSOT for an offline-capable feature, you still have to decide how it gets refreshed from the network, and that choice is usually the pivotal decision in the whole interview because it shapes every data flow after it.

Cache-first, also called offline-first, serves whatever is already in the local database immediately and only reaches for the network on a cache miss or an explicit user refresh. It optimizes for availability: the screen always has something to show, even with no signal. Network-first always tries the network first and only falls back to the cache if that call fails; it optimizes for correctness when stale data would be actively wrong, like a live stock price. Stale-while-revalidate splits the difference: it shows the cached, possibly stale data immediately for a fast first paint, then quietly refetches from the network in the background and updates the UI when the fresh response lands. Most feeds, like a news list or a social timeline, want stale-while-revalidate, because a slightly old headline is harmless but a blank loading spinner on every open is not.

fun getNews(): Flow<List<Article>> = flow {
    emit(dao.getAll())           // stale → instant display
    try {
        val fresh = api.fetchArticles()
        dao.upsertAll(fresh)     // write to DB
        emit(dao.getAll())       // fresh → UI updates
    } catch (e: IOException) {
        // stale data already shown; swallow or surface error
    }
}

An infinite, frequently changing list needs a pagination strategy that survives the list changing under it while you're scrolling. Offset or limit pagination, ?page=3&size=20, is the simplest to implement, but it drifts: if a row is inserted or deleted between two requests, every later offset shifts, so you either skip an item or see a duplicate. It also gets slow, a database has to walk past every skipped row to reach a large offset. Keyset or seek pagination, WHERE id > :lastId ORDER BY id LIMIT 20, is indexed and fast and doesn't drift the same way, but it leaks the ordering column into the client's request, which ties your API contract to a specific field.

Cursor pagination hands the client an opaque, server-issued token instead of a page number or a raw column value. The client doesn't know or care what's inside the token, it just echoes it back to ask for the next page. That opacity is the whole point: the server can change its underlying ordering or storage without breaking clients, and because the token encodes a stable position rather than a row count, insertions and deletions elsewhere in the list don't cause drift. For an infinite, changing feed, cursor pagination is usually the right call.

data class FeedResponse(
    val items: List<FeedItem>,
    val nextCursor: String?   // opaque token, not a page number
)

interface FeedApi {
    @GET("feed")
    suspend fun getPage(
        @Query("cursor") cursor: String? = null,
        @Query("limit")  limit: Int = 20
    ): FeedResponse
}

Paging 3 is how you actually implement cursor-style pagination with local caching instead of just describing it. A PagingSource is the simplest version, it loads pages directly, typically straight from the network or straight from Room. For an offline-capable list you go one step further and add a RemoteMediator: it sits above Room and is responsible for deciding when to fetch the next network page and writing whatever it fetches into the database.

Critically, the PagingSource that the UI actually collects from reads from Room, not from the network. The RemoteMediator only ever writes into Room; it never hands data straight to the UI. That means Room stays the single source of truth for the paged list exactly the way it does for the rest of the offline-first design, and the list keeps scrolling even if the device goes offline mid-scroll, it just stops being able to load pages it hasn't cached yet.

@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
    private val api: ArticleApi,
    private val db: ArticleDatabase
) : RemoteMediator<Int, Article>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Article>): MediatorResult {
        val page = 1
        return try {
            val items = api.getArticles(page)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) db.articleDao().clearAll()
                db.articleDao().insertAll(items)   // network → Room (SSOT)
            }
            MediatorResult.Success(endOfPaginationReached = items.isEmpty())
        } catch (e: IOException) { MediatorResult.Error(e) }
    }
}

A screen that tracks loading, success, empty, and error as four independent booleans can drift into states that make no sense, isLoading and isError both true at once, for instance, and now the UI has to guess which one wins. The fix is to model the screen's state as a single sealed type with one case per actual state, exposed as a StateFlow from the ViewModel.

Because the cases are sealed, a when over them is exhaustive, the compiler forces you to handle every case and complains if you add a new one and forget a branch somewhere. The states are mutually exclusive by construction: you cannot be Loading and Error at the same time because they are different values of the same variable, not two flags that happen to both be true. It's also worth explicitly distinguishing Empty from Loading, a genuinely empty result and a still-fetching one can look identical on screen but call for different messaging and different retry behavior.

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
}

when (val s = uiState.collectAsStateWithLifecycle().value) {
    is HomeUiState.Loading -> CircularProgressIndicator()
    is HomeUiState.Empty   -> Text("Nothing here yet")
    is HomeUiState.Success -> ItemList(s.items)
    is HomeUiState.Error   -> Text(s.message)
}

A real API returns more than 200s, and a design that only plans for the happy path falls apart under interview follow-up questions. Model failure explicitly, per status code, rather than one generic retry-everything catch block.

A 429 means the server is rate-limiting you. The right response is to back off exponentially, doubling the wait after each attempt, add a small random jitter so a fleet of clients doesn't retry in lockstep, and honor a Retry-After header if the server sends one. Retrying immediately in a tight loop is the worst possible response to a 429, it makes the throttling worse, not better. A 401 means the access token expired: refresh it once and retry the request, and only force a full re-login if the refreshed token also fails. A 5xx or a network exception gets a bounded number of retries and then an explicit Error state with a retry action, never a silent failure the user can't act on.

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
            val retryAfterMs = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()?.times(1000)
            val backoffMs = retryAfterMs
                ?: (2.0.pow(attempt) * 1000 + Random.nextLong(0, 500)).toLong()
            delay(backoffMs)
        }
    }
    return block()
}

Background sync for something like an offline news reader needs to be periodic, connectivity-aware, and survive the app being killed, and none of a raw background service, a main-thread loop, or a repeating alarm satisfy all three. WorkManager is the tool built specifically for deferrable, guaranteed background work: you describe constraints like 'only run when connected' and a period like 'every hour,' and the system handles scheduling, retries, and surviving process death and even reboots.

A perpetual foreground service that just sits there syncing wastes battery on a persistent notification for something that only needs to run occasionally. A while-true loop on the main thread blocks the UI and gets killed the moment the app backgrounds. An alarm firing every second is both wildly wasteful and not what AlarmManager is for, it's meant for exact-time events like a calendar reminder, not periodic polling. WorkManager's constraint system is also what makes it connectivity-aware: the work simply won't run until the network constraint is satisfied, so you're not burning a battery cycle retrying against a dead connection.

val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "news_sync",
    ExistingPeriodicWorkPolicy.KEEP,
    syncRequest
)

A background music player is a different background-work problem than periodic sync, it needs to keep running continuously and audibly while the user does something else entirely, which is exactly what a foreground service exists for. The modern, recommended approach is androidx.media3's MediaSessionService, run as a foreground service that shows a media-style notification with playback controls.

The notification isn't optional decoration, it's what tells the system this is ongoing, user-visible work and keeps the process from being killed the moment the app leaves the foreground. A plain Service playing audio with no foreground notification gets killed by the system almost immediately once the app backgrounds. WorkManager is the wrong tool here too, it's for deferrable work with a defined end, not an indefinite, latency-sensitive stream the user is actively listening to right now. And playing audio directly on the UI thread from within an Activity dies the instant the user navigates away or rotates the screen.

class PlaybackService : MediaSessionService() {
    private lateinit var mediaSession: MediaSession

    override fun onCreate() {
        super.onCreate()
        val player = ExoPlayer.Builder(this).build()
        mediaSession = MediaSession.Builder(this, player).build()
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession

    override fun onDestroy() {
        mediaSession.release()
        super.onDestroy()
    }
}

A worked example makes the framework concrete. Take a real-time chat feature: low latency and two-way delivery matter more than almost anywhere else in the app, a message needs to appear on the recipient's screen in under a second, not after the next poll interval. A persistent WebSocket is the right transport for that, it stays open and pushes messages in both directions the moment they happen, instead of the client repeatedly asking 'anything new?' the way a REST poll would.

But a raw WebSocket by itself isn't offline-capable, and chat history needs to survive the app being killed and reopened. So the same SSOT pattern from the rest of this lesson applies here too: incoming messages get written into Room the instant they arrive over the socket, and the UI observes Room, not the socket connection directly. Sending a message follows the same idea in reverse, write it to Room immediately with a pending status for instant UI feedback, then push it over the socket, and update its status once the server acknowledges it. Polling a REST endpoint every 60 seconds, or a single fetch when the screen opens with no further sync, both fail the latency requirement badly.

class ChatRepository(private val dao: MessageDao, private val ws: WebSocketClient) {
    val messages: Flow<List<Message>> = dao.observeAll() // UI observes Room (SSOT)

    init {
        ws.onMessage { json ->
            val msg = Json.decodeFromString<Message>(json)
            dao.insert(msg)               // incoming → Room → Flow re-emits
        }
    }

    suspend fun send(text: String) {
        val msg = Message(id = uuid(), text = text, status = PENDING)
        dao.insert(msg)                   // optimistic local write
        ws.send(Json.encodeToString(msg))
    }
}

Closing out a design with a testing strategy shows you're thinking about maintainability, not just the happy path demo. Android's own guidance is to prefer fakes over mocks for repositories and data sources. A fake is a real, working implementation, an in-memory list standing in for Room, say, that behaves like the production code rather than a script of canned responses. Because a fake actually behaves like the thing it replaces, tests built on it stay valid as the code around it evolves, where a mock's brittle, over-specified expectations tend to break on refactors that didn't change behavior at all.

Layer the rest of the strategy on top of that: unit test ViewModels and repositories against fakes, use Turbine to assert the exact sequence of Flow or StateFlow emissions, spin up an in-memory Room database for DAO tests so you're exercising real SQL, and use MockWebServer to script HTTP responses for the API layer without hitting a real network. Compose UI or instrumented tests round it out at the screen level. Naming this, layer by layer, unprompted, is usually the difference between a design that sounds complete and one that just sounds like a diagram.

Back to Mobile System Design