Case study

ReadThat 1: Client Architecture

6 min read

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/platform
app -> feature -> core

Domain 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
|
v
network response -> Room transaction -> Flow/StateFlow -> ViewModel state -> Compose

Room 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) }
}
}

FeedViewModel.kt#L23-L68

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>()

FeedRemoteMediator.kt#L19-L53

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):

DataL1L2 / source of truth
SDUI feedbounded Paging cache (ten pages)Room feed_groups, item_state, remote_keys
Votescurrent Room-backed page stateitem_state + coalescing vote_outbox
Commentsbounded account/post LRUnormalized Room comment_threads, comment_nodes
ImagesCoil memory cachebounded Coil disk cache
Videoone process player + preload queuesbounded 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’s PreCacheHelper to 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

LibraryRoleKMP?
Jetpack Compose + Material 3All UIAndroid here (Compose MP exists; out of scope for this series)
Room 2.8L2 source of truth, all structured dataAndroid here (Room KMP exists; schema lives in :core:data)
Paging 3 + RemoteMediatorFeed/search/media paging over RoomAndroid
WorkManagerOutbox drains, refresh, telemetryAndroid
Media3 (ExoPlayer, DefaultPreloadManager, SimpleCache)VideoAndroid
Coil 3Images, over the shared transportMultiplatform library, used on Android
Navigation ComposeScreen graph, shared-element transitionsAndroid
SavedStateHandleCollapse sets, pager positions across process deathAndroid
JankStatsFrame health telemetryAndroid
kotlinx.serialization / coroutines / FlowWire models, all asyncKMP
:core:model (contracts, reducers)Domain contractsKMP: Android + iOS + JS
:core:observability (events, timers)Vendor-neutral telemetry contractKMP: 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 →