Every concrete app design in this catalog - Weather App, Notes App, Newsfeed, Messenger, Photo Gallery, Music Player, Doc Editor, Ride-Sharing, and others to come - inherits the architecture in this article. What changes between them is the domain: audio, video, location, real-time messaging. What doesn't change is the skeleton underneath.
This page is deliberately long, with eight deep dives, to cover the many common angles an Android system-design question can probe, treat it as a reference to return to, not a single sitting.
Google has defined a very structured architecture for modern Android apps. It covers most of the common cases, which is what makes knowing it the key to Android system-design questions. This list is a mix of the canonical architecture and stack choices:
- Unidirectional Data Flow (UDF) - the three-layer architecture (UI / Domain / Data) runs on UDF: events flow down (the screen emits an intent, the layers below decide what it means), state flows up (each layer exposes a
Flow, the screen is a pure function of the latest value). One owner per layer, one direction each. - Compose - Android's modern UI toolkit. A screen is a
@Composablethat takes an immutableUiStateand renders it, when the state changes, Compose recomposes only the nodes whose inputs changed. - ViewModel + StateFlow - the
ViewModelowns the screen's state as aStateFlow<UiState>(a hot stream that always has a current value). Survives rotation, the screen never loses state. - Room + Retrofit - Room is Android's SQLite wrapper (on-disk cache), Retrofit is a REST client. Data arrives as Kotlin
Flows from both. - Use cases - thin Kotlin classes wrapping one or two repository calls plus business logic the UI shouldn't know about. They keep the
ViewModeloff the data layer, making it unit-testable without Room. - Hilt - the DI framework that wires the layers at compile time. A missing binding fails the build, not the app on launch. Modules sit at the two layer seams, everything else is constructor injection.
The architectural core is usually follows - UDF, the repository over a two-tier cache, optimistic writes through an outbox, cursor pagination, multi-module architecture - and only the details change per app. The API surface in this writeup uses a generic /v1/items namespace and the domain entities are placeholders, a concrete design swaps in its own resource names and content types.
In mobile system design, the interesting design pressure is boundary discipline, the technology usually defaults to the same stack. Compose + Hilt + Room + OkHttp + Coil is the established Android combo, what separates a working app from a maintainable one is where the layers cut. Boundry discipline usually shows up here:
The shape of the code - where the layers cut and who enforces it:
- Layering is the contract, not a preference (dd1) - features that skip the boundary become unowned code, harder to test, harder to maintain.
- The repository is the only thing that knows there's a network (dd2) - everything above it sees a
Flow, everything below it is plumbing. - Multi-module is a day-one decision (dd6) - the build (compilation) cost compounds linearly with feature count, smaller modules let gradle be more efficient in incremental builds
The data paths - how reads, writes, and errors move:
- Tap→render must beat the network round-trip (dd3) - the write path inverts the read path: optimistic UI first, server ack as a side effect.
- Errors are normal, not exceptional (dd4) - retry budget, banner state, idempotency keys, silent failure is the worst outcome on a flaky network.
- Pagination is cursor or wrong (dd5) - offset drifts under top-insert, only opaque cursors survive a re-ranking server.
Requirements
Clarifying Questions
| Question | Answer | What it signals |
|---|---|---|
| Platform - Android only, or cross-platform? | Android only | Locks this stack. KMP (Kotlin Multiplatform) keeps the 90% (data + domain), React Native/Flutter replaces all of it. You will rarely be asked to design in other frameworks (unless you already know the role requires that) |
| User interactions in scope? | Like + comment, no creation flow | Like + comment are simple writes. A creation flow forces a multi-stage write pipeline. |
| Offline reads? | Yes | Forces a local cache as the read source-of-truth. Otherwise the repository is a network proxy. |
| Offline writes? | Yes | Forces writes to survive process death. Otherwise tap-send loses to process death or duplicates on retry. |
| Real-time updates? | Stretch only | The template stays REST-only. WS / SSE (WebSocket / Server-Sent Events) or FCM (Firebase Cloud Messaging - Google's push service) is each concrete design's call. |
| Authentication, friend graph, accounts? | Out of scope | Assume signed-in, every call carries Authorization: Bearer <token>. No login flows in this design. |
| Low-end devices, global audience? | Yes | Bounds cache sizes, decoder pools, and image budgets. Rules out "premium hardware on WiFi" defaults. |
Functional Requirements
- Show a list of items, tap an item to see its detail
- Like and comment on items
- Use the app offline - browse previously seen content and queue interactions
- Scroll an arbitrarily long list (infinite scroll)
- See images attached to items
- See network failures surfaced clearly, never silently lose user input
Non-Functional Requirements
- Frame budgetUI state updates &l;16 ms, no disk or network on the main thread
- Cold startFirst screen renders in <100 ms on a cold start, offline included
- Offline toleranceReads work entirely from cache, writes survive process death and reboot
- Local storageCache bounded to ~12.5% of available heap, on-disk cache capped (e.g. 200 MB), cleared on logout
- Battery and data efficiencyNo background polling, minimal network traffic on metered connections
- Network toleranceGraceful degradation on cellular, lossy, and high-latency networks, transient failures recover without user action
- Sync convergenceAfter reconnect, local state reconciles to server state in <30 s at p95 (the slowest 5% of syncs)
- ScaleHolds at the 100 M DAU (daily active users) scale concrete designs target
Data Model + API
Generic Domain Types + REST Surface
The domain entities below are placeholders (this is a "Template App" design). Lines tagged // template are the contract - the fields and shapes you'll need in ~90% of concrete app designs. Lines tagged // specialized are the few substitution points: each concrete design picks its own content type, its own ActionKind values, and its own REST resource name.
// The unit the list and detail screens render. Concrete designs specialize content.
data class Item(
val id: String, // template - server-assigned, stable across edits
val authorId: String, // template
val createdAt: Instant, // template
val content: ItemContent, // specialized - sealed type per concrete app
val counters: Counters, // template - server-side, eventually consistent
val myActions: MyActions, // template - viewer state, locally optimistic
)
// The outbox row. One per queued user action. Idempotency key client-side.
data class OutboxAction(
val id: String, // template - client-generated UUID, the dedup key
val itemId: String, // template
val kind: ActionKind, // specialized - LIKE, UNLIKE, COMMENT, ...
val payload: String? = null, // template - e.g. comment body, null for likes
val state: ActionState = ActionState.PENDING, // template - PENDING, SENT, FAILED
val createdAt: Instant = Clock.System.now(), // template
)Two important notes:
Pagination is opaque-cursor. The server returns nextCursor, the client passes it back on the next request and never tries to parse it. That lets the server switch between id-based, time-based, keyset, or signed-cursor schemes without a client rev, and unlike offset pagination, inserts at the head don't shift the window mid-scroll.
Mutations are idempotent on a client-generated actionId. Idempotent just means safe to send more than once: the server commits it only once no matter how many copies arrive. The client mints a UUID before queuing the write, sends the same id on every retry, and the server dedupes on it. Generating the id client-side is what lets the outbox dispatcher and the server agree on the dedup key without a round-trip, so the client can retry as aggressively as it likes and the server will only commit once.
# List
GET /v1/items?cursor=<opaque>&limit=20 # template - resource path specialized
→ { items: [Item], nextCursor } # template - Item type specialized
# Detail
GET /v1/items/{itemId} # template - resource path specialized
→ Item # template - Item type specialized
# Actions (idempotent on actionId, server dedupes)
POST /v1/items/{itemId}/actions # template - resource path specialized
body: { actionId, kind, payload? } # template - kind values specialized
→ { item: Item } # template - Item type specialized, echoes server state
# Auth - every call carries Bearer, the server resolves user from the token
Authorization: Bearer <token> # template1. a cache of what the server told it (an in-memory L1 over an on-disk L2, both behind the repository)
2. a queue of writes it hasn't confirmed yet (the outbox table).
Optimistic UI, retries, and offline scrolling are all built on those two stores. The optimistic write mechanic (tap → cache + outbox → server ack → reconcile) is unpacked in the Write Path deep dive (dd3).
High-Level Design
Architecture
The simplest system that satisfies the requirements is six layers deep. The top three (UI, Domain, Data) are the client's own tiers, written in pure Kotlin, the bottom three (Local Storage, Network, Backend) are the platform surfaces and the wire.
A screen renders a UiState held by a ViewModel, a use case sits between the ViewModel and the one ItemRepository, the repository fans down to an L1 in-memory StateFlow, an L2 Room database (cache plus a durable outbox), and a RemoteDataSource built on OkHttp + Retrofit. Coil's ImageLoader (uses the same OkHttp pool). A WorkManager-driven OutboxDispatcher is the only thing that turns queued writes into network calls.
The repository is the only component wired to both the cache stack and the network - the layers above it consume a Flow and never learn whether an item came from disk or the wire.
Durability seam for writes: the outbox lives in the same Room database as the on-disk cache, so a queued likes survive process death exactly the way cached items do.
Read path (dd2), write path (dd3), and push path (dd8) are all covered in deep dives.
Happy-Path - Cold Start, Scroll, Tap-Like
Read this once for the gist - it traces a real session end to end, and every step that looks dense (the cache ladder, the optimistic write) gets its own deep dive in Phase 4.
- App launches, the list screen's
ViewModelsubscribes toobserveItems()on the use case, which subscribes torepository.observeItems(). repository.observeItems()returns the L1StateFlowsynchronously - empty on cold start, then populated when Room's observer fires from L2 (one disk read, a few ms). UI renders the cached page in <100 ms.- Meanwhile,
repository.refresh()firesRemoteDataSource -> OkHttp -> APIfor the head page. On response, the new items land in L2 in a single transaction, and L1 follows. - Room's
Flowobserver fires, the use case re-emits, theStateFlow<UiState>updates, Compose re-composes only the rows that changed. - User scrolls near the end of the cached pages, Paging 3's
RemoteMediatortriggersload(APPEND)with the lastnextCursor, new page is appended to L2 and the diff flows up. - User taps Like: the
ViewModelcallsperformAction(itemId, LIKE). Inside one Room transaction the repository applies the optimistic update to the cachedItem(counter +1,myActions.liked = true) and inserts anOutboxActionrow, Room'sFlowobserver fires on commit, so the new state propagates upward automatically - no manual emit needed. - The outbox dispatcher (a long-running coroutine or
WorkManagerworker) reads PENDING rows in FIFO (first-in-first-out) order andPOSTs each via OkHttp, on 2xx the row is deleted and the server's authoritativeItemreplaces the optimistic L2 row, on transient failure the row stays and is retried with exponential backoff. - Configuration change or process death does not lose anything: the UI rebuilds from L2 via the same
Flow, and the outbox dispatcher resumes draining on next launch.
Deep Dives
A concrete app design picks a feed, a chat, a player, a doc. The template underneath - what is a "screen", how does it get data, where do business rules live - is identical across all of them. Skipping the layering means every feature reinvents the boundary, getting it wrong means feature teams trip over each other every quarter.
Three layers, each owning one thing:
- UI (Composable + ViewModel) - renders the current state and emits user intents.
- Domain (use cases) - the business rules: what a "like" means, what counts as a valid comment, which query shape a given view needs.
- Data (repository) - the cache and the network. The single component that touches both Room and OkHttp; to the domain layer above it is just a
Flowsource.
Unidirectional Data Flow (UDF) is the rule the three layers enforce: no layer reaches across or mutates another layer's state.
Why this matters in practice
A bug like "the like count is wrong" has a small search space - it must be either an emit-from-below or a render-from-above. Without UDF, the same bug could be in any of N code paths that write the shared state, and the only way to find it is to read all of them.
How state actually moves up: cold Flow, hot StateFlow
The data layer hands you a cold Flow that only runs while something is watching - collecting dao.observeAll() opens a Room query that re-emits whenever the table changes, and each collector re-runs the producer. The ViewModel turns that into a hot StateFlow<UiState> that always has a value ready for the screen, so Compose gets a value now and a rotation doesn't restart the query. The rest of the Flow mechanics live in State Management and Concurrency.
The use-case design rule
The rule that interviewers probe: reads multiply by view, writes converge through one entrypoint.
- Reads: every new way to view data (filter by author, search, sort, archived-only) needs its own use case, because each runs a different query. You can't reuse one.
- Writes: a new action (like, unlike, comment, bookmark) does not need a new use case. The plumbing is identical for all of them - open a transaction, apply the change, queue an outbox row, kick the dispatcher. Only the action kind changes, so it's just a new enum value plus one branch in the switch.
The smell it warns against: LikeItemUseCase, UnlikeItemUseCase, CommentUseCase as separate classes - N copies of the same plumbing, varying only in the kind string they pass down. Explodes the domain layer for nothing.
The exception: if an action needs real pre-write logic (validate a comment, rate-limit, route to a different repository), put that in a domain helper the one use case calls - don't spin up a parallel use case class for it.
Hilt turns the layer boundaries into build errors - modules sit at the two seams, and the domain and data layers should still compile with every Hilt import stripped. The mechanics are covered in Dependency Injection.
A scrolling list that makes an API call per visible row is unshippable on cellular. A cache that returns stale data after a server-side update is worse than no cache - the staleness is silent. How do we orchestrate the cache ladder so the UI never sees the network directly and the cache stays consistent with the wire?
Keep a fast in-memory copy (L1) in front of the on-disk Room cache (L2, Android's SQLite layer) so scrolling never waits on a database query, and let the repository hide the network behind one Flow.
The diagram below is the read-path delta - the four lanes the read touches. UI and Domain are pass-through (the screen subscribes via ObserveItems) and omitted. L1 is a hot mirror of L2, kept hydrated by Room's Flow observer, reads return L1 directly with no SQLite round-trip:
ViewModel.init { fetch() } hits the API on every screen entry. No offline support, flaky on cellular, every navigation event pays the round-trip cost. The first time a user opens the app on a subway the screen is empty - the experience is "is this broken?".
The repository exposes observeItems(): Flow<List<Item>> backed by Room. refresh() calls the API and writes results into Room, Room's observer fires, the Flow re-emits. Offline reads work. The problem: every read after the cache is hydrated still goes through a SQLite query, which is on the order of milliseconds. On a scrolling list with row-level reads (counters, my-actions state), that cost shows up in frame-time budgets.
The repository keeps an L1 MutableStateFlow<List<Item>> as a hot mirror of L2. On construction it subscribes to L2's Room Flow and pipes every emission into L1 - so L1 is never a lazy-fill cache that suffers misses, it's always already populated by the time anything reads it. Reads return L1 directly with no SQLite round-trip on the read path. Writes hit L2 inside a transaction, the L2 → L1 mirror handles propagation. refresh() writes the network response to L2 the same way, and Room's observer drives the same L1 update.
class ItemRepositoryImpl @Inject constructor(/* dao, remote, scope */) : ItemRepository {
private val l1 = MutableStateFlow<List<Item>>(emptyList())
init {
// L1 is a hot mirror of L2, Room's Flow drives it.
dao.observeAll().onEach { l1.value = it.toDomain() }.launchIn(scope)
}
override fun observeItems(): Flow<List<Item>> = l1
override suspend fun refresh() {
val page = remote.fetchItems(cursor = null) // network
dao.replaceHead(page.items.toEntities()) // L2 write, L1 hydrates via observer
}
}L1 is bounded by Runtime.maxMemory() / 8, a deliberately conservative slice of the heap. L2 is bounded at 200 MB with LRU eviction and is cleared on logout. The boundary is enforced at write time, not at read - the dao.insert path checks size after the insert and evicts the oldest rows if over. Because L1 is smaller than L2, it holds the observed working set, not all of L2: the unbounded infinite-scroll feed is paged from Room by Paging 3 (the pagination dive), while L1 is the hot path for detail and bounded-list reads.
repository.observeItems() and gets an always-hot Flow backed by L1, refresh is a separate concern the repository owns, with the network → L2 → L1 update happening behind the same Flow. Concrete designs vary cache sizes and eviction policy, the ordering (L1 mirrors L2, L2 is the durable store, remote is the supplier) is invariant.Tap-like must light up the heart within one frame (~16 ms). Waiting for a network round-trip is impossible. The user might be offline, might tap-then-untap five times in two seconds, or the server might be down for ten minutes. None of these are exceptional - they're the normal envelope. How do we render the tap instantly and reconcile with the server later?
Update the screen instantly, write the action into a durable queue (the "outbox"), and let a background worker push it to the server - so a tap survives offline, a flaky network, and the OS killing the app.
The outbox pattern is borrowed from email clients - Gmail and Outlook have shipped it for a decade because a dropped like and a dropped email have the same failure mode: silent loss. The difference is email users notice, feed users don't, so the bar for apps is lower and most teams clear it by accident.
The diagram below is the write-path delta - the four lanes the write touches. UI and Domain are pass-through (the tap arrives via PerformAction) and omitted. The outbox lives in the same Room database as the item cache, so a queued write survives process death exactly the way cached items do:
viewModelScope.launch { api.like(itemId) }. Kill the app mid-flight and the launched coroutine goes down with the ViewModel, so the like never reaches the server. The UI updates only on response, so the heart lags 100-500 ms behind the tap. On any failure, the UI shows the unliked state and the user assumes the tap didn't register.
Update the L1/L2 state immediately, fire the API call, retry on transient failure. The UI feels instant. The flaw: if the app dies between optimistic write and network success, the local state says "liked" but the server doesn't know. On next launch the like silently disappears when the server's value comes back.
Tap-like is two writes inside one Room transaction - apply the optimistic mutation to the cached Item and append an OutboxAction row. Room's Flow observer fires on commit, so the UI re-renders without an explicit emit. A separate OutboxDispatcher reads PENDING rows in FIFO order and dispatches them via OkHttp. On success the row is deleted and the server's authoritative Item replaces the optimistic one. On failure the row stays and is retried with exponential backoff.
One perform() entrypoint covers every action kind. The orchestration (transaction + outbox row + dispatcher kick) is identical for LIKE, UNLIKE, COMMENT, and any future kind, only the optimistic-update branch and the payload differ. The when (kind) lives in applyOptimisticUpdate, not in the use case layer - adding BOOKMARK is one new enum value and one new branch, not a new use case.
suspend fun perform(itemId: String, kind: ActionKind, payload: String? = null) {
val actionId = UUID.randomUUID().toString()
db.withTransaction {
applyOptimisticUpdate(itemId, kind, payload) // when(kind) → LIKE +1, COMMENT push, ...
outboxDao.insert(OutboxAction(actionId, itemId, kind, payload))
}
outboxDispatcher.kick() // best-effort wake
}
private suspend fun applyOptimisticUpdate(itemId: String, kind: ActionKind, payload: String?) {
when (kind) {
LIKE -> dao.toggleLike(itemId, liked = true)
UNLIKE -> dao.toggleLike(itemId, liked = false)
COMMENT -> dao.insertLocalComment(itemId, body = payload!!)
// new kinds add a branch here, not a new use case.
}
}The dispatcher side: what kick() actually wakes. outboxDispatcher.kick() just pokes a background worker to wake up and drain the queue - it's best-effort, so calling it twice does no harm. Under the hood it resolves to a WorkManager.enqueueUniqueWork(...) call: the same WorkManager durability the push path leaned on, with pending work persisted and resurrected after process death. The worker is constructed fresh per execution and borrows the singleton DAOs and API client for one doWork() call.
enqueueUniqueWork with ExistingWorkPolicy.KEEP is the coalescing knob: a burst of LIKE / UNLIKE taps calls kick() many times in a second, the second-through-Nth calls are no-ops because a worker is already enqueued under the unique name. The first run drains everything PENDING in FIFO order, so coalescing happens naturally at the queue level rather than via per-tap debouncing. The KEEP policy itself and the backoff semantics on Result.retry() are WorkManager mechanics; the design move here is choosing KEEP so a burst collapses, not the mechanics of the policy.
The same WorkManager primitive backs the push path - the FCM receiver enqueues a parallel ItemsRefreshWorker that calls ItemRepository.refresh(), Deep Dive 8 unpacks the push-path side.
What this buys: exactly-once delivery across retries, because the server dedupes on actionId, durability across process death, because the outbox row lives in Room and the dispatcher resumes draining on next launch, and cheap rapid taps, because the KEEP policy above collapses a burst into one drain. (An optional refinement cancels a LIKE still sitting in the queue when its UNLIKE arrives, so neither ships.)
Step through one outbox row across the four situations it has to survive - a coalesced double-tap, a transient 5xx, a permanent 4xx, and a process kill - and watch where the optimistic UI, the queue, and the server agree or diverge:
On mobile, network errors are the default. I've shipped apps where the #1 crash for the first week was SocketTimeoutException - not because the code was wrong, but because nobody planned for the user walking between rooms and losing wifi mid-request. A bad server release at 9am, a CDN edge that just rebooted, a metro tunnel - all normal. Every API call needs to handle transient failure, distinguish it from a real error, and surface the right thing. Silent failure is worse than a crash: it hides, surfaces days later in a different part of the app, and nobody can trace it back. How do we make every API call resilient without drowning the UI in retry logic?
Three layers each own one kind of error: OkHttp retries the transient ones, the repository maps HTTP codes to domain failures, and the UI shows an offline banner for session-wide outages.
Retrying transient failures
Retries belong at the network layer, not in app code. An OkHttp Interceptor (a hook that sits in the middle of every request and response, like middleware - see HTTP Client Library) examines the response and the request method, on a 5xx or a SocketTimeoutException, it retries up to N times with min(maxBackoff, base * 2^attempt) + uniform(0, base) between attempts. The jitter is what prevents the install base from synchronising retries against a recovering server. Idempotent methods (GET, HEAD, PUT, DELETE, plus POST carrying an Idempotency-Key the way the outbox does) retry, non-idempotent POST and PATCH don't - retrying them risks duplicate effects on the server.
class RetryInterceptor(private val maxAttempts: Int = 3) : Interceptor {
override fun intercept(chain: Chain): Response {
var attempt = 0
while (true) {
val res = runCatching { chain.proceed(chain.request()) }
if (res.isSuccess && !res.getOrNull()!!.shouldRetry()) return res.getOrNull()!!
if (++attempt >= maxAttempts || !chain.request().isRetryable()) return res.getOrThrow()
Thread.sleep(backoff(attempt) + Random.nextLong(0, JITTER_MS)) // jittered backoff
}
}
}See the HTTP Client Library design for the deeper retry-semantics treatment - Retry-After handling, method-aware idempotency, and the connection-pool behavior under failure.
Repository maps HTTP errors to domain failures
4xx errors are not retryable - the request was wrong. The repository wraps the Retrofit call in a runCatching { ... } and translates HttpException(400..499) into a sealed DomainError type (NotFound, Forbidden, BadInput, ...). The UI matches on the sealed type, not on raw HTTP codes.
Retrying a 4xx is always a bug, even though it looks harmless. The server told you "no" - bad input, forbidden, not found. Retrying burns battery on a response that won't change. The retry budget is for transient failures, and 4xx is the definition of permanent.
UI banner state for network outages
Wifi loss is not an error per request - it's a session state. A NetworkObserver (built on ConnectivityManager.registerDefaultNetworkCallback) emits a Boolean Flow that the UI overlays as a "No connection" banner whenever it's false. Errors on individual requests inside an offline session don't show as toasts - the banner is the right signal because it explains why everything is failing at once.
NotFound) from infrastructure failure (network), the UI distinguishes "this request failed" from "we're offline". Each layer's error vocabulary is local to its concerns.A naive offset + limit pagination drifts the moment the server inserts at the top of the list between two fetches: page 2's offset still says "items 20-39", but those items have shifted to 25-44. Ranked feeds re-score under the user's feet, so positional pagination is wrong by construction. How do we page through a list that keeps shifting?
Never paginate by position (offset or timestamp), pass back an opaque cursor the server hands you, and let Paging 3 drive Room as the source of truth.
Offset pagination is the #1 thing I reject in design review. Ship the cursor on day one, it's the same code.
GET /v1/items?offset=20&limit=20 names page 2 by position. Insert an item at the head between two fetches and every later offset points one row off, so the boundary either repeats a row or drops one, against a feed that re-ranks under the user, no offset is ever correct. It only holds when the data is genuinely static, which a live feed never is - and that is the failure the next tier has to remove.
GET /v1/items?since=2026-05-12T10:00:00Z. Sometimes proposed as a "fix" for offset, but it's the same antipattern with a different axis: the client is still naming a position in some server-controlled ordering. It breaks three ways. It breaks on ties, when two items share a millisecond and interleave inconsistently across calls. It breaks for ranked feeds, where "since" is meaningless. And it breaks the moment the server re-ranks, because the since value now points into an ordering that no longer exists. Tempting because it works for the demo (a strict chronological feed with sparse timestamps), ships broken to production.
The server encodes whatever it needs in the cursor - (rank, itemId, salt) for ranked, (ts, itemId) for chronological. The client passes it back verbatim and never parses it. Paging 3's RemoteMediator is the canonical Android integration: a PagingSource reads pages from Room (the cache is the source of truth), the RemoteMediator hydrates Room from the network on cache miss. The three LoadType values (REFRESH, PREPEND, APPEND), the remote_keys table that holds the cursor per item, and the single-withTransaction write that keeps the cached page and the keys consistent are Paging 3 + RemoteMediator mechanics; the design move here is choosing the opaque cursor as the contract the server and client both commit to, not the mediator plumbing.
See the Newsfeed App design for the re-ranking-under-pagination corner case and the cursor-expired (410 Gone) protocol that keeps the client correct when the server's ranking snapshot ages out.
Watch the offset window drift the instant the server re-ranks - and how an opaque cursor that commits the snapshot survives the same re-rank with no duplicate at the page boundary:
Flow<PagingData<T>> and the offline story falls out for free.A single :app module that compiles in four minutes blocks every engineer on the team. Every UI change rebuilds the data layer, every data layer change rebuilds every feature. By year two the build is the bottleneck and the migration to a modularised structure is a multi-quarter project nobody wants to own. How do we avoid the year-two migration entirely?
Split the app into per-feature modules on day one, the cost is a few hours of Gradle now, and it saves a multi-quarter migration nobody wants to own at year two.
I've had apps where the single-module build hit 15 minutes. Every UI tweak rebuilt the data layer, every data change rebuilt every feature, and the team's feedback loop died. Making a module split today is easier than ever - point your AI agent at it and let it work on splitting. It's not really system design content, but it's real life pain that's very easily solvable regardless, and interviewers are looking at the practicality of your thinking.
The right shape is straightforward and the patterns are well-established at Airbnb, Netflix, Square, and Google's own consumer apps.
Per-feature modules
Each feature is a pair of Gradle modules: :feature:foo (UI + ViewModels) and :feature:foo-data (domain + data + DI). The UI module depends on its data module's domain interfaces only - the implementations are wired via Hilt. A feature module never depends on another feature module, cross-feature calls go through an API module (:feature:foo-api) that declares the interface and is depended on by both.
Shared library modules
Cross-cutting infrastructure lives in :lib: modules: :lib:network (OkHttp + Retrofit setup), :lib:database (Room base configuration), :lib:designsystem (theme, common Compose components), :lib:common (utilities). Features depend on libs, libs never depend on features.
A representative module structure
:app ← assembles everything, no logic
:feature:feed ← UI + ViewModels
:feature:feed-data ← domain + data + Hilt wiring
:feature:feed-api ← public interface for cross-feature calls
:feature:detail ← UI + ViewModels
:feature:detail-data
:feature:detail-api
:lib:network ← OkHttp + Retrofit base
:lib:database ← Room base + migrations
:lib:designsystem ← Compose theme + components
:lib:common ← utilities, extensionsThree practical benefits follow from the split. (1) Build time - touching :feature:feed UI doesn't rebuild :feature:detail. That isolation also bounds failure: (2) a bug in one feature's data layer can't reach into another's. And the team scales with the module count: (3) three engineers can work on three features without merge conflicts in shared files.
App Process box for readability.Images are where mobile performance dies quietly. A single 4000x3000 JPEG decoded at full size is 48 MB resident - enough to OOM (out-of-memory crash) a Pixel 3a. A list of twenty such images freezes scroll. Re-downloading the same hero image on every screen entry costs metered data and battery. Picking a library doesn't solve any of this - the placeholder dims, decode size, and cache budgets are the rest. How do we load images without OOM, jank, or wasted bandwidth?
Big images eat memory and decode time, and the library will not save you: set the display size before loading and decode smaller-than-original. (ARGB_8888 is 4 bytes per pixel, the default - see Memory and GC.)
Coil is the Compose-era default (coroutine-based, AsyncImage-native, shares OkHttp with the API client). The rest of this deep dive is what you do with it - none of which Coil does for you by default.
1. Placeholder dimensions, not placeholder drawables
The bug everyone hits first: an AsyncImage with no size hint renders at 0×0 until the bytes decode, then the layout reflows and everything below it jumps. Users read this as "the app is broken". The fix is to give Compose the dimensions before the bytes arrive - either from width/height metadata the server sends with each Item, or from a fixed aspect ratio the feature commits to.
AsyncImage(
model = item.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(item.imageAspectRatio), // 16:9, 1:1, whatever the API returned
contentScale = ContentScale.Crop,
)The aspectRatio modifier reserves the layout slot before the image loads. No reflow on decode, the placeholder is a colored rectangle of the correct size. This single modifier is the difference between a list that scrolls cleanly and one that bounces.
2. Downsampled decode
Coil auto-downsamples to the target Size the request is rendered at - but only if the target size is known at request time. Sizing the AsyncImage with aspectRatio (above) gives Coil the target dimensions automatically, off-screen requests need explicit .size(...).
ImageRequest.Builder(context)
.data(url)
.size(Size(400, 225)) // explicit target size
.scale(Scale.FILL) // crop rather than letterbox
.allowHardware(true) // Bitmap.Config.HARDWARE: available API 26+, safe to default from 28
.build()Why the target size matters - a 4000x3000 JPEG decoded at native resolution is 48 MB at ARGB_8888, decoded for a 400-px column it's a few hundred KB - is the two-pass-decode / inSampleSize / Bitmap.Config.HARDWARE story, and it's covered in Image Loading Internals. The design move here is giving Coil the dimensions so it picks the right sample size; the platform substrate that makes the sample size the right lever lives on that page.
3. Two-tier cache, parallel to the item cache
Coil's caches mirror the item-data ladder - same shape, separate budgets. The memory cache holds decoded Bitmaps, the disk cache holds the compressed bytes off the wire; the full ladder (cache key shape, hit-miss path through decode and network) is covered in Image Loading Internals. What this design pins is the budgets:
- L1 memory cache - Coil defaults to ~20% of available heap (15% on low-RAM devices), this design pins it to
Runtime.maxMemory() / 8to match the item L1 budget. - L2 disk cache - Coil defaults to 2% of free disk space (clamped to 10-250 MB) in
cacheDir, for image-heavy apps pin it to 200 MB.
val imageLoader = ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder(context).maxSizePercent(0.125).build() } // 12.5% heap
.diskCache { DiskCache.Builder().maxSizeBytes(200L * 1024 * 1024).build() } // 200 MB
.okHttpClient(appOkHttpClient) // share the API client's connection pool + TLS
.build()Sharing appOkHttpClient is the under-noticed move: connection reuse to the image CDN, the same retry/auth interceptors, and a single TLS session pool. Two separate OkHttp instances pay the handshake cost twice. The disk cache also drives OkHttp's If-None-Match / If-Modified-Since so a server 304 Not Modified returns cached bytes without re-downloading.
4. Memory pressure handling
On onTrimMemory(...) Android signals the app to release caches before the OOM killer steps in. Coil registers a callback that trims the memory cache under pressure (halving it at TRIM_MEMORY_RUNNING_LOW) and clears it entirely once the app is backgrounded, the disk cache is unaffected (off-heap). The next visible image loads from disk - one-frame stutter, no crash. On a low-end device this is what keeps the app on screen when the user multitasks into a heavy game and back.
A server-side change - someone else liked your post, a new message arrived - has to reach the phone without the phone polling. A 30-second poll keeps the radio warm and still averages 15 s of latency, a long-lived socket per app is a battery cost no single app should own. How does the backend wake the app on demand, and why does the push carry a trigger instead of the data?
FCM (Firebase Cloud Messaging - Google's push service, the one persistent socket Play Services maintains for every app on the device) is the wake-up channel, the REST API is still the source of truth. The receiver enqueues a WorkManager job and returns immediately, the worker calls repository.refresh() - the same entrypoint a cold-start read uses - so the network call stays inside the repository and the cache update stays single-owner.
The diagram below is the push-path delta - only the three lanes the push touches. UI, Domain, and Local Storage are unchanged from the Phase 3 architecture and omitted. The refresh flows left through RemoteDataSource → REST API (the cold-start path), FCM enters from the right:
viewModelScope.launch { while (true) { delay(30_000); fetch() } }. The radio stays warm, battery drains, and p95 latency is the interval - 15 s on average. Polling inverts the right direction: the phone asks the server, instead of the server telling the phone.
The server sends a notification block, Play Services auto-displays it when backgrounded, and no app code runs - so there is no re-fetch, no local-state suppression, no update of an existing notification. The user sees a banner, but the app's cache is stale until the next cold start.
The server sends a data-only payload - a small key-value map like { "type": "items_changed" } carrying no item state. onMessageReceived is always called (foreground, background, or freshly woken from kill), and it does one thing: enqueue a WorkManager job. The worker calls repository.refresh(), which fetches the authoritative state from the REST API and writes it through the same L2 → L1 → Flow chain a cold-start read uses. The push decides when to ask, the REST API is still the answer.
Why data-only and not notification: the notification payload auto-displays on Play Services' terms, with no chance for the app to suppress based on local state or update an existing notification. Production apps send data-only so onMessageReceived runs every time and the app's code decides what to surface, the full tradeoff is in Push and Notifications.
Two transport knobs cross both payload kinds. priority - high wakes the device immediately and bypasses Doze, normal batches with the next maintenance window, and abusing high gets the app deprioritized. collapse_key - several messages with the same key queued while offline collapse to the latest on reconnect, right for "items changed", wrong for chat.
In code, the pattern is two short classes - the receiver does nothing but enqueue, the worker does nothing but call ItemRepository.refresh():
@AndroidEntryPoint
class PushReceiver : FirebaseMessagingService() {
@Inject lateinit var workManager: WorkManager
override fun onMessageReceived(msg: RemoteMessage) {
// Push payload is a *trigger* ("something changed"), not data.
workManager.enqueue(OneTimeWorkRequestBuilder<ItemsRefreshWorker>().build())
}
}
@HiltWorker
class ItemsRefreshWorker @AssistedInject constructor(
@Assisted ctx: Context, @Assisted params: WorkerParameters,
private val itemRepository: ItemRepository,
) : CoroutineWorker(ctx, params) {
override suspend fun doWork() =
runCatching { itemRepository.refresh() }
.fold(onSuccess = { Result.success() }, onFailure = { Result.retry() })
}Why the receiver enqueues instead of doing the work directly. onMessageReceived runs on a binder thread with a ~10-second budget before ANR, calling repository.refresh() there risks blowing it on a slow network. Enqueueing defers to a durable worker that survives both the binder timeout and process death.
The worker carries NetworkType.CONNECTED. A push that lands while offline enqueues a worker that won't run until connectivity returns. WorkManager persists the request, so the deferred refresh survives reboot.
The self-healing property. The client reconciles the full head page on every cold start anyway, so a missed or coalesced push is caught by the next launch. Push is a latency optimization, not a correctness mechanism - and FCM delivery is not guaranteed under Doze or battery-saver modes, so it can't be.
WorkManager is the durability primitive on both the push path and the write path - it persists pending work to its own SQLite DB and resurrects it after process death. The write-path counterpart that drains the outbox queue is unpacked in dd3.
repository.refresh() entrypoint a cold-start read uses, so the push path adds one new component (the receiver) and zero new data paths.Tradeoffs
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Architecture | Three-layer UDF (UI / Domain / Data) with Hilt at boundaries | Two-layer (UI / Data) without use cases | Use cases decouple the UI from network shape, ViewModels stay unit-testable |
| Async | Kotlin Coroutines + Flow | RxJava | Compose-native, Google's recommended stack, legacy bridges available |
| DI | Hilt | Dagger / Koin | Android-aware modules + Navigation scoping out of the box |
| Local DB | Room | SQLDelight | Larger ecosystem on Android-only projects, SQLDelight wins on KMP |
| Network | OkHttp + Retrofit | Ktor | De facto standard, interceptor chain + connection pool are battle-tested |
| Image loading | Coil + placeholder dims + downsampled decode | Glide | Compose-native, coroutine-based, shares OkHttp pool, Glide is the legacy View-system answer |
| Pagination | Paging 3 + opaque cursor | Hand-rolled offset paging | Cursor survives top-insert and re-rank, Paging 3 owns the cache+network seam |
| Module structure | Per-feature pair + shared libs from day 1 | Single :app module | Build times, blast radius, team parallelism - migration cost compounds |
Related core concepts
The mechanisms on this page are each developed in depth in a core concept:
- Architecture - the three-layer UDF stack, the repository boundary, and where the layers cut (Deep Dive 1).
- Dependency Injection - Hilt as the contract enforcer at the two layer seams.
- Persistence - Room as the L2 cache and the durable outbox, observed as
Flows (Deep Dive 2 and 3). - State Management - cold Flow converted to a hot
StateFlow<UiState>withstateIn(Deep Dive 1). - Caching Strategies - the two-tier cache, stale-while-revalidate, and the L1/L2 ladder (Deep Dive 2).
- Offline-First and Sync - the durable outbox, idempotency keys, and reconciliation (Deep Dive 3).
- Pagination - opaque server cursors via Paging 3 + RemoteMediator (the pagination dive).
- Background Work and Scheduling - WorkManager as the outbox dispatcher and push-path trigger (the write path and push path).
- Push and Notifications - FCM as a wakeup hint rather than a data channel, data vs notification payloads, and token rotation (Deep Dive 8).
- Network Protocols - the REST surface, OkHttp interceptor retry, and idempotency keys.
- Image Loading Internals - Coil with placeholder dims, downsampled decode, and the shared OkHttp pool (the image loading dive).
Done reading? Mark it so it sticks in your dashboard.