ReadThat 1: Client Architecture
Layered feature modules, MVVM with unidirectional data flow, offline-first Room, a two-tier cache contract, metered-aware background workers, and the full Jetpack inventory, including which pieces are Kotlin Multiplatform.
Part 1 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.8 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
The client has one governing rule: render durable local state, then reconcile it with the network. Every other decision, the module graph, the cache tiers, the workers, exists to serve that rule.
Module graph
Features are vertical slices with ui, domain, and data packages. Core modules hold capabilities shared by two or more features. :app is a thin composition root: navigation, workers, and backend adapters.
:app (composition root, navigation, workers, backend adapters) ├── :feature:feed ────────────► :core:model, :core:data, :core:media ├── :feature:comments ────────► :core:model, :core:media ├── :feature:search ──────────► :core:data ├── :feature:communities ─────► :core:model + :core:data ├── :feature:community-detail ► :core:data + :core:observability ├── :feature:mediafeed ───────► :core:data + :core:media + :core:post ├── :core:data (shared Room schema/DAOs) ├── :core:network (one process transport: API + Coil + Media3) ├── :core:media ────► :core:model + :core:network ├── :core:post ─────► :core:data + :core:model + :core:observability └── :core:observability (KMP event contract)settings.gradle.kts#L18-L62 · docs/ARCHITECTURE.md
The allowed dependency direction is enforced, not aspirational:
UI -> domain <- data -> network/platformapp -> feature -> coreDomain code does not depend on Compose, Room, HTTP, or platform classes. UI never calls a network client or a DAO. Cross-feature navigation passes identifiers, never mutable objects: :feature:comments has no dependency on :feature:feed, yet feed dwell-prefetch and detail share one retained repository through the app’s composition root.
MVVM and unidirectional data flow
Each screen has one observable state and explicit intents:
Compose event -> ViewModel intent -> repository command | vnetwork response -> Room transaction -> Flow/StateFlow -> ViewModel state -> ComposeRoom is the authoritative stream. A successful network response is committed before it becomes visible. Optimistic actions (votes, posts, community creation) write visible local state and an outbox row in one transaction; a worker reconciles later.
Paging 3 makes the feed ViewModel almost disappear: no page list, no cursor, no isAppending flag:
class FeedViewModel( private val repository: FeedRepository, ...) : ViewModel() {
/** * `cachedIn(viewModelScope)` is not optional. * Without it, every configuration change re-collects the Pager from * scratch — refetching page one and dropping the user's scroll position. */ val feed: Flow<PagingData<CellUi>> = repository.feed().cachedIn(viewModelScope)
/** Optimistic like: writes item_state; Room invalidates the PagingSource; Paging re-emits the affected page. No manual list surgery. */ fun toggleLike(itemId: String) { if (itemId.isBlank()) return viewModelScope.launch { repository.toggleLike(itemId) } }}Offline-first, by construction
The network↔database seam is a Paging 3 RemoteMediator. It never serves pages to the UI; Room’s PagingSource does. The mediator only runs when the DB runs out of cached pages:
/** * - Offline works by construction. With no network, APPEND fails and * Paging keeps serving whatever the DB already holds. * - Paging survives process death, because the cursor is a row. * - One writer. Both the mediator and an optimistic like write to Room; * Room invalidates the PagingSource and the UI re-emits. */class FeedRemoteMediator( private val accountId: String = CacheScope.DEFAULT_ACCOUNT_ID, private val feedId: String = CacheScope.HOME_FEED_ID, private val db: AppDatabase, private val remote: FeedRemoteSource, ...) : RemoteMediator<Int, GroupWithState>()Startup follows the same rule. MainActivity renders a cached shell immediately and reports fully drawn at that boundary; no network request is on the first-frame path. Measured on a Pixel 10 Pro with a warm process: p50 = 11 ms across ten am start -W samples. A cold process was 575 ms.
The two-tier cache contract
Every user-visible data family has an L1 memory tier and an L2 durable tier, and every structured key includes the account identity. A condensed view (the full table covers thirteen families):
| Data | L1 | L2 / source of truth |
|---|---|---|
| SDUI feed | bounded Paging cache (ten pages) | Room feed_groups, item_state, remote_keys |
| Votes | current Room-backed page state | item_state + coalescing vote_outbox |
| Comments | bounded account/post LRU | normalized Room comment_threads, comment_nodes |
| Images | Coil memory cache | bounded Coil disk cache |
| Video | one process player + preload queues | bounded Media3 segment cache |
The data layer page covers sizing and eviction; the point here is the contract: L1 never independently decides correctness. It removes query and mapping work from the hot render path. Room decides what exists.
Background workers
All deferred work runs through WorkManager, all of it network-constrained, all of it idempotent via client-minted mutation IDs:
FeedRefreshWorker: one unique periodic worker; refreshes Room first, then (only on unmetered, validated networks with autoplay enabled) asks Media3’sPreCacheHelperto persist the first two seconds of the first ready video.- Vote / post / community outbox workers: unique one-time workers that drain durable outboxes. A post targeting a locally pending community waits at an explicit ordering barrier; when the community reconciles, dependent posts re-enqueue immediately instead of sitting in backoff.
- Telemetry drain: up to 50 events per request, immediate when connected, 15-minute safety sweep.
FeedWorkers.kt · PostUploadWorker.kt · SubredditCreationWorker.kt
Concurrency is handled where it actually bites: Paging and WorkManager create independent sync objects, so feed serialization uses a process-wide account/feed mutex, and an append re-reads its cursor after acquiring it, so a cursor captured before a competing refresh can never append an older generation.
Jetpack inventory
| Library | Role | KMP? |
|---|---|---|
| Jetpack Compose + Material 3 | All UI | Android here (Compose MP exists; out of scope for this series) |
| Room 2.8 | L2 source of truth, all structured data | Android here (Room KMP exists; schema lives in :core:data) |
Paging 3 + RemoteMediator | Feed/search/media paging over Room | Android |
| WorkManager | Outbox drains, refresh, telemetry | Android |
Media3 (ExoPlayer, DefaultPreloadManager, SimpleCache) | Video | Android |
| Coil 3 | Images, over the shared transport | Multiplatform library, used on Android |
| Navigation Compose | Screen graph, shared-element transitions | Android |
SavedStateHandle | Collapse sets, pager positions across process death | Android |
| JankStats | Frame health telemetry | Android |
| kotlinx.serialization / coroutines / Flow | Wire models, all async | KMP |
:core:model (contracts, reducers) | Domain contracts | KMP: Android + iOS + JS |
:core:observability (events, timers) | Vendor-neutral telemetry contract | KMP: Android + iOS + JS |
The KMP boundary is deliberate: contracts and pure logic are shared; platform-owned infrastructure (DB, player, workers) is not. Observability details live in part 7.
📷 Screenshot placeholder: module graph rendered, plus the app shell on first frame.
Next: Part 2: The SDUI feed →