Implement a generic read-through/write-through cache layer over Room and a REST API, then verify it with unit tests.
The Interface
interface Cache<K, V> {
suspend fun get(key: K): V?
suspend fun put(key: K, value: V)
suspend fun invalidate(key: K)
suspend fun invalidateAll()
}
Step 1: Read-Through Cache
In a read-through cache, a miss automatically fetches from the source and populates the cache:
class ReadThroughCache<K, V>(
private val localSource: suspend (K) -> V?,
private val remoteSource: suspend (K) -> V?,
private val store: suspend (K, V) -> Unit,
private val ttlMs: Long = 15 * 60 * 1000L
) {
private val timestamps = mutableMapOf<K, Long>()
suspend fun get(key: K): V? {
val ts = timestamps[key]
val isExpired = ts == null || System.currentTimeMillis() - ts > ttlMs
val local = localSource(key)
if (local != null && !isExpired) return local
// Read-through: fetch from remote, populate cache
val remote = remoteSource(key) ?: return local // return stale if remote fails
store(key, remote)
timestamps[key] = System.currentTimeMillis()
return remote
}
}
Step 2: Write-Through Cache
In a write-through cache, every write goes to both the local store and the remote source synchronously:
class WriteThroughCache<K, V>(
private val localWrite: suspend (K, V) -> Unit,
private val remoteWrite: suspend (K, V) -> Unit,
private val localDelete: suspend (K) -> Unit,
private val remoteDelete: suspend (K) -> Unit
) {
suspend fun put(key: K, value: V) {
remoteWrite(key, value) // write remote first
localWrite(key, value) // then update local
}
suspend fun delete(key: K) {
remoteDelete(key)
localDelete(key)
}
}
Step 3: Wiring to Repository
class ArticleRepository(
private val dao: ArticleDao,
private val api: ArticleApi
) {
private val readThroughCache = ReadThroughCache(
localSource = { id: String -> dao.getById(id)?.toDomain() },
remoteSource = { id: String ->
try { api.getArticle(id) } catch (e: IOException) { null }
},
store = { id: String, article: Article ->
dao.insert(article.toEntity())
},
ttlMs = 10 * 60 * 1000L // 10 minutes
)
private val writeThroughCache = WriteThroughCache(
localWrite = { _: String, article: Article -> dao.insert(article.toEntity()) },
remoteWrite = { _: String, article: Article -> api.updateArticle(article) },
localDelete = { id: String -> dao.deleteById(id) },
remoteDelete = { id: String -> api.deleteArticle(id) }
)
suspend fun getArticle(id: String): Article? = readThroughCache.get(id)
suspend fun updateArticle(article: Article) = writeThroughCache.put(article.id, article)
suspend fun deleteArticle(id: String) = writeThroughCache.delete(id)
}
Step 4: Unit Tests with Fakes
class ReadThroughCacheTest {
private var localStore = mutableMapOf<String, String>()
private var remoteStore = mapOf("a1" to "Article 1")
private var remoteCallCount = 0
private val cache = ReadThroughCache(
localSource = { key -> localStore[key] },
remoteSource = { key -> remoteCallCount++; remoteStore[key] },
store = { key, value -> localStore[key] = value },
ttlMs = 1000L // 1 second for tests
)
@Test fun `cache miss fetches from remote and stores locally`() = runTest {
val result = cache.get("a1")
assertEquals("Article 1", result)
assertEquals(1, remoteCallCount)
assertEquals("Article 1", localStore["a1"])
}
@Test fun `cache hit returns local without calling remote`() = runTest {
localStore["a1"] = "Cached Article 1"
val result = cache.get("a1")
assertEquals("Cached Article 1", result)
assertEquals(0, remoteCallCount) // no remote call
}
@Test fun `expired cache re-fetches from remote`() = runTest {
localStore["a1"] = "Stale Article 1"
val cache = ReadThroughCache(
localSource = { key -> localStore[key] },
remoteSource = { key -> remoteCallCount++; remoteStore[key] },
store = { key, value -> localStore[key] = value },
ttlMs = 0L // immediately expired
)
val result = cache.get("a1")
assertEquals("Article 1", result)
assertEquals(1, remoteCallCount)
}
}
Common Pitfalls
| Problem | Fix |
|---|---|
| Write-through fails halfway | Wrap both writes in a transaction or use the outbox pattern for resilience |
| Read-through stampede | Add a Mutex per key to prevent concurrent reads fetching the same remote data |
| TTL not surviving process restarts | Store cachedAt in Room, not an in-memory map |
| Cache misses in tests | Ensure test fakes return null for unset keys, not throw |