Why KacheController
The problem
Adding a cache to a repository layer is repetitive: check cache → miss → fetch DB → store in cache → return. Every method needs the same structure, the same serialisation boilerplate, and the same invalidation logic duplicated in every write. Bugs in one place silently diverge from another.
What KacheController does differently
KacheController inverts the relationship: instead of wrapping a cache client in your repository, you wrap your database lambda in a controller call. The read-through and write-through logic lives once, in the library.
// without KacheController
suspend fun getUser(id: String): User? {
val cached = redis.hget("myApp:users", id)
if (cached != null) return json.decodeFromString(cached)
val user = db.find(id)
if (user != null) redis.hset("myApp:users", id, json.encodeToString(user))
return user
}
// with KacheController
suspend fun getUser(id: String): User? =
controller.get(id, usersCollection, User.serializer()) { find(id) }
Cache backend separation
CacheClient is a thin interface. Swapping Redis for in-memory (in tests) or SQLite (in offline scenarios) requires changing one constructor argument, not the repository code.
Volatile query results
Filtered lists and aggregates are stored in a dedicated volatile hash and invalidated atomically on any write. There is no manual invalidation call to forget.
Comparison
| Feature | Manual caching | KacheController |
|---|---|---|
| Read-through | Hand-coded per method | Built in |
| Write-through | Hand-coded per method | Built in |
| Volatile invalidation | Manual, error-prone | Automatic |
| Cache backend swap | Invasive refactor | Constructor argument |
| Serialisation | Repeated boilerplate | One serializer argument |
| Empty collection handling | Often missed | Built-in sentinel |
| Write-behind | Non-trivial to implement safely | setAsync / setAllAsync |
How it works
Each controller method:
- Checks
cacheEnabled()— if false, calls the database lambda directly. - Reads from the cache backend using
hgetorhgetAll. - On hit: deserialises and returns immediately.
- On miss: calls the database lambda, serialises the result, stores it with
hset, then returns. - For writes: stores the result, clears the volatile hash with
DEL.
All serialisation uses kotlinx-serialization JSON. Cache keys are deterministic strings derived from the database schema — no configuration required.