Case study

ReadThat 1: Client Architecture

7 min read✦ AI generated with human guidance & review

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.3 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

Android and iOS now have one product implementation: :feature:app-ui coordinates the KMP screen modules on both platforms, and :core:client owns the shared ViewModel, controllers, and offline-first repositories. The former Android-only UI/features and their rollback flag have been deleted; :app remains only where Android genuinely needs an APK/process host.

:app (Android APK, WorkManager, process lifecycle)
└── :composeApp (Android entrypoint, process graph, saved-state adapter)
:iosApp (SwiftUI lifecycle + narrow Apple shims)
└── :composeApp (exported iOS framework + process graph)
:composeApp
└── :feature:app-ui (KMP application coordinator + navigation)
├── :feature:feed-ui / detail-ui / mediafeed-ui / search-ui /
│ profile-ui / creation-ui / settings-ui / community-ui /
│ auth-ui / ad-ui / shell-ui (KMP screens)
├── :core:design / :core:image-ui / :core:media-ui (shared UI + optimized target actuals)
├── :core:media-acquisition(+-ui) / :core:sharing(+-ui) (shared policy; platform actuals)
├── :core:navigation (shared destinations, bounded/versioned restoration)
└── :core:client (shared MVVM controllers + offline-first repositories)
├── :core:data (Room 3 KMP schema/DAOs, commonMain)
├── :core:network (shared contract; HttpEngine/OkHttp + URLSession actuals)
├── :core:model / :core:deeplink
└── :core:observability (KMP event contract)
Native capability modules keep platform engines behind shared contracts:
:core:media / :core:media-ui / :core:image-ui
:core:media-acquisition(+-ui) / :core:sharing(+-ui)

settings.gradle.kts · 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:feed-ui and :feature:detail-ui stay independent while feed dwell-prefetch and detail share one retained repository through the application 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 removes the usual hand-written page list, cursor, and isAppending state. SharedFeedController owns the lifecycle-independent feed behavior; the shared ReadThatViewModel retains its Room-backed pages for both Android and iOS:

/**
* Lifecycle-independent feed controller shared by focused feature ViewModels
* and the application ViewModel. Navigation and native rendering stay at the
* host edge; Room Paging, refresh classification, mutations, comment prefetch
* and media handoff have one implementation.
*/
internal class SharedFeedController(
private val repository: OfflineFirstRepository,
...
) {
val cards: Flow<PagingData<FeedCard>> = repository.pagedFeedFor(...)
fun toggleLike(itemId: String) = vote(itemId, 1)
fun vote(itemId: String, value: Int) {
if (itemId.isBlank() || value !in -1..1) return
scope.launch { repository.votePost(itemId, value, performanceSurface) }
}
}
class ReadThatViewModel(...) : ViewModel() {
private val cachedPagedFeed = feedController.cards.cachedIn(viewModelScope)
val pagedFeedCells = cachedPagedFeed.map { paging ->
paging.flatMap { card -> card.cells }
}
}

SharedFeedController.kt · ReadThatViewModel.kt#L212-L221

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.
*/
private inner class SharedFeedRemoteMediator(
private val account: String,
private val feedId: String,
private val subreddit: String?,
...
) : RemoteMediator<Int, GroupWithState>()

OfflineFirstRepository.kt#L461-L572

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 a bounded hot tier and a durable tier, and every structured key includes the account identity. A condensed view (the full contract covers the remaining 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
Comments/post headersretained controller StateFlowbounded Room cached_documents rows
Imagesbounded platform decoded cacheCoil/shared-client stable-key disk cache
Videoone native process player + preload queuesMedia3 segment cache / AVFoundation facilities

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

On Android, deferred work runs through WorkManager; the mutation lanes are network-constrained and idempotent through client-minted mutation IDs. iOS calls the same shared maintenance/repository operations from a BGTaskScheduler host.

  • FeedRefreshWorker: one unique periodic worker; refreshes Room first, warms a bounded image/poster plan on a validated path, then (only on unmetered, non-Data-Saver networks with autoplay enabled) asks Media3’s PreCacheHelper to persist the first two seconds of at most one 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?
Compose Multiplatform + Material 3All UI: :feature:*-ui KMP screens render Android and iOS from one codebaseKMP: Android + iOS
Room 3 (KMP)L2 source of truth; one schema and DAO set in commonMain for Android and iOSKMP
Paging 3 + RemoteMediatorFeed/search/media paging over Room, implemented in :core:clientKMP
WorkManagerOutbox drains, refresh, telemetryAndroid
Media3 (ExoPlayer, DefaultPreloadManager, SimpleCache)VideoAndroid
Coil 3Images, over the shared transportMultiplatform library, used on Android
:core:navigation + Compose saveable stateDestinations, bounded history, screen/pager restorationKMP policy/UI; native state-registry adapters
SavedStateHandle / @SceneStoragePersist the same versioned navigation snapshotAndroid / iOS
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, UI, ViewModels/controllers, Room schema/DAOs, repositories, and paging policy are shared. Platform code supplies the optimized engines and lifecycle hooks—Media3 vs AVPlayer, HttpEngine/OkHttp vs URLSession, WorkManager vs Apple background scheduling, secure storage, pickers, and system share sheets. Observability details live in part 7.

📷 Screenshot placeholder: module graph rendered, plus the app shell on first frame.


Next: Part 2: The SDUI feed →