ReadThat 5: The Data Layer
Room as the source of truth, account-scoped schema, repositories as the only layer that knows the network exists, L1 memory + L2 disk for structured data, memory + disk for media, and the actual logic behind every cache size.
Part 5 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.
Room is the source of truth
One database, one schema owner (:core:data), shared by every feature, and since the Android/iOS convergence it is Room 3 KMP: the schema, DAOs, and entities sit in commonMain and compile for both platforms. Repositories are the only layer that knows the network exists; screens observe Room-backed flows and never learn where a row came from.
Viewer-owned caches and query results are account-scoped throughout the schema, so signing out or switching accounts cannot expose another account’s rows. Feed/media/search/document tables use compound keys beginning with accountId; globally identified account rows and mutation-ID outboxes still carry an explicit account owner:
@Entity( tableName = "feed_groups", primaryKeys = ["accountId", "feedId", "groupId"],)data class GroupEntity(...)
@Entity( tableName = "item_state", primaryKeys = ["accountId", "itemId"],)data class ItemStateEntity(...)
@Entity( tableName = "remote_keys", primaryKeys = ["accountId", "feedId"],)data class RemoteKeyEntity(...)
@Entity( tableName = "vote_outbox", primaryKeys = ["accountId", "itemId"],)data class PendingVoteEntity(...)Notable separations:
- Order vs viewer state.
feed_groupsstores each server-described occurrence in a feed scope, keyed by(accountId, feedId, groupId)with a unique(accountId, feedId, sortIndex)ordering index. The same post may occur in several feed scopes, but all occurrences join the one account/postitem_staterow. - Structure vs viewer state.
item_state(votes, viewed) lives beside, not inside, cached payloads, so an optimistic vote doesn’t rewrite a server blob, and one write shows in feed, MediaFeed, and detail at once. - Durable intent.
vote_outbox,post_outbox,subreddit_outbox, membership/visit queues, and telemetry outboxes survive process death. Where a mutation has visible optimistic state, that state and its command row are committed atomically.
The DAO returns a Room-generated PagingSource, which is what makes the DB, not the network, the thing the UI pages over; Room invalidates it on any write to either joined table (FeedDao.kt#L17-L47).
Two tiers for structured data
L1 process memory (bounded, per-family) ↓ mirrors (never decides correctness)L2 Room (source of truth) ↓ filled byRemote source (repository-owned)L1’s job is to remove query and mapping work from the hot render path, nothing more. Three representative shapes:
Paging’s built-in L1 for the feed: cachedIn(viewModelScope) retains ~ten pages; rotation re-attaches to the same pages instead of refetching.
Retained state over bounded Room documents for comments, post headers, profiles, and discovery envelopes. These resources are account-scoped JSON rows because their wire shape is already a document. The table is capped at 512 rows per account and pruned after 30 days; a screen controller retains the currently rendered value as a StateFlow:
@Entity( tableName = "cached_documents", primaryKeys = ["accountId", "cacheKey"],)data class CachedDocumentEntity( val accountId: String, val cacheKey: String, val payloadJson: String, val updatedAt: Long,)
private suspend fun putDocument(key: String, payload: String) { val account = accountScope() documents.upsert(CachedDocumentEntity(account, key, payload, platformEpochMillis())) documents.pruneToLimit(account, MAX_CACHED_DOCUMENTS)}Entities.kt#L416-L432 · OfflineFirstRepository.kt#L1645-L1691
An explicit 32-entry LRU for search snapshots. SharedSearchRepository uses a mutex-protected LinkedHashMap: a hit is removed and reinserted at the tail, while overflow removes the first key. The read path is memory → Room → network, falling back to stale Room data on a transient fetch failure (SharedSearchController.kt#L319-L438).
The shared repository also coalesces in-flight phase-one comment requests: feed dwell prefetch and tap-through to the same account/post await one CompletableDeferred, then hand off through Room (OfflineFirstRepository.kt#L826-L850).
Media caches: memory and disk
Media gets a parallel ladder with different math, because the unit is bytes, not rows.
Images:
- L1 memory: Android’s Coil decoded cache is 12% of app memory on low-RAM devices, 20% otherwise; iOS uses a bounded 64 MiB decoded-image LRU that is released under memory pressure.
- L2 disk: Android’s Coil compressed-byte cache is quota-aware and capped at 64/128/256 MiB. iOS’s process-scoped shared client owns a stable-key 512 MiB file cache (plus its bounded byte-memory tier) and disables URLCache to avoid a second signed-URL-indexed copy.
- Cache keys are content identities, independent of the expiring signed CDN URL, and name the rendition (
feedvsdetail). A rotated signature never cold-starts the cache.
Video:
- Android L1/L2: the 12 MiB aggregate Media3 preload-sample budget (part 3) plus the player’s bounded buffer, and one process
SimpleCacheholding segments only. Dynamic HLS/DASH manifests are never disk-cached. - iOS L1/L2: one process AVPlayer with owner-scoped adjacent
AVURLAssetwarming; AVFoundation remains responsible for native HLS playback/offline facilities. The shared app cache still owns posters and preview bytes. - Media identity is stable across feed, MediaFeed, and detail and does not depend on expiring delivery URLs.
- Staged uploads live in
noBackupFilesDir: process-safe, excluded from backup, cleaned after the outbox drains.
The sizing logic, generalized. Every cache answers three questions: what’s the unit of reuse (row, bitmap, segment), what’s the pressure signal (heap for memory, quota for disk, decoder count for players), and what’s the miss cost (a query, a decode, a network round trip). Memory caches size as heap ratios and respond to trimMemory; disk caches size as storage-tiered fixed caps; anything with a hardware ceiling (decoders) is a singleton. Nothing is unbounded, including the eight-scope retention limit for MediaFeed generations and the bounded telemetry outboxes.
📷 Diagram placeholder: full cache ladder, structured + media, with budgets annotated.
Consistency rules that make it hold together
- Persist before emission: a network response commits to Room before any flow emits it.
- One writer per surface: mediator and optimistic writes both go through Room; there is no in-memory list surgery to keep in sync.
- Mutex + re-read: competing Pagers serialize on a striped account/feed mutex and re-read cursors after acquiring it; late ACKs can’t overwrite newer outbox intent.
- Idempotency keys: every durable outbox row stores a client-minted stable ID, so retries replay rather than duplicate. Immediate comment writes also carry an idempotency ID, but their client path is not yet a durable outbox.
← Part 4: Post detail & comments · Next: Part 6: Backend architecture →