Case study

ReadThat 5: The Data Layer

5 min read

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.8 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. Repositories are the only layer that knows the network exists; screens observe Room-backed flows and never learn where a row came from.

The schema is account-scoped everywhere: every primary key starts with accountId, so signing out or switching accounts can never leak another account’s rows:

@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-L177

Notable separations:

  • Order vs identity. feed_groups stores server render blobs keyed by (accountId, feedId, sortIndex); the same post can appear in several feeds without duplication, and a unique index on the triple enforces the ordering invariant.
  • 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, plus telemetry outboxes. User intent is a row before it’s a request. Same transaction as the visible optimistic change.

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

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

A small synchronized LRU for comments and headers:

/** Small synchronized LRU used by repositories; disk remains authoritative. */
internal class BoundedLruCache<K : Any, V : Any>(private val maxEntries: Int) {
private val entries = LinkedHashMap<K, V>(maxEntries, 0.75f, true)
operator fun get(key: K): V? = synchronized(entries) { entries[key] }
fun put(key: K, value: V) = synchronized(entries) {
entries[key] = value
while (entries.size > maxEntries) {
val iterator = entries.entries.iterator()
iterator.next()
iterator.remove()
}
}
}

BoundedLruCache.kt#L4-L23

LinkedHashMap with accessOrder = true is the LRU; eviction is the loop at the tail. The read path everywhere is L1 → Room → network, and every successful or optimistic change writes both tiers before the next render. Search adds its own 32-entry query/result LRU with the same contract.

Repositories also coalesce in-flight requests: a comment prefetch triggered from feed dwell and a tap-through to the same post share one network call (CommentsRepository.kt).

Media caches: memory and disk

Media gets a parallel ladder with different math, because the unit is bytes, not rows.

Images (Coil):

  • L1 memory: decoded bitmaps: 12% of app memory on low-RAM devices, 20% otherwise. Percentage-of-heap, not a fixed number, because a decoded bitmap cache must scale with what the device can give back under pressure; isLowRamDevice gates the smaller ratio.
  • L2 disk: compressed bytes, quota-aware, capped at 64/128/256 MiB by available storage. Tiered fixed caps rather than a percentage: disk pressure is about coexistence with other apps, and StorageManager quota queries decide the tier.
  • 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 (Media3):

  • L1: the 12 MiB aggregate preload sample budget (part 3) plus the player’s own buffer (5 s back-buffer, bounded forward buffer).
  • L2: one process SimpleCache in app-private storage, LRU-evicted, holding segments only; dynamic HLS/DASH manifests are never cached. Segment keys are media-stable, shared by feed, MediaFeed, and detail.
  • 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 mutation carries a client-minted stable ID stored with its outbox row; retries replay, they don’t duplicate.

← Part 4: Post detail & comments · Next: Part 6: Backend architecture →