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.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(...)Notable separations:
- Order vs identity.
feed_groupsstores 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 byRemote 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() } }}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;
isLowRamDevicegates 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
StorageManagerquota queries decide the tier. - 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 (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
SimpleCachein 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 →