Case study

ReadThat 5: The Data Layer

6 min read✦ AI generated with human guidance & review

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

Entities.kt#L52-L178

Notable separations:

  • Order vs viewer state. feed_groups stores 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/post item_state row.
  • 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 by
Remote 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 (feed vs detail). 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 SimpleCache holding segments only. Dynamic HLS/DASH manifests are never disk-cached.
  • iOS L1/L2: one process AVPlayer with owner-scoped adjacent AVURLAsset warming; 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 →