androidengineers.Book a session

Caching Strategies

Exercise: Read-Through/Write-Through Cache

exercise50 minHard

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

ProblemFix
Write-through fails halfwayWrap both writes in a transaction or use the outbox pattern for resilience
Read-through stampedeAdd a Mutex per key to prevent concurrent reads fetching the same remote data
TTL not surviving process restartsStore cachedAt in Room, not an in-memory map
Cache misses in testsEnsure test fakes return null for unset keys, not throw

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Read-Through/Write-Through Cache | Android System Design | Android Engineers