How It Works
A top-down walkthrough of what happens inside KacheController on each operation, using MongoDB + Redis as the concrete example.
The two Redis keys every collection gets
When any method is called on a MongoCollection, KacheController derives two Redis keys from the collection's namespace:
"myApp:users" ← primary key (Redis HASH)
"myApp:users:volatile" ← volatile key (Redis HASH)
Both are Redis hashes — a map of field → value stored under a single key — not simple string keys.
Primary hash — one field per document. Field = document id, value = JSON-serialised document:
myApp:users
"abc123" → '{"id":"abc123","firstName":"Alice","lastName":"Smith"}'
"def456" → '{"id":"def456","firstName":"Bob","lastName":"Jones"}'
Volatile hash — one field per cached query result. Field = whatever key you named it, value = JSON array or scalar:
myApp:users:volatile
"users:role:admin" → '[{"id":"def456",...}]'
"users:count" → '42'
get — read one document
controller.get(userId, usersCollection, User.serializer()) {
find(Filters.eq("_id", userId)).firstOrNull()
}
HGET "myApp:users" "abc123"
→ hit: deserialise JSON → return User (MongoDB never contacted)
→ miss: run lambda (MongoDB find)
HSET "myApp:users" "abc123" "{...json...}"
DEL "myApp:users:volatile"
return User
The volatile clear on a miss looks counterintuitive, but it's a safety measure: a cache miss means the primary hash was out of sync, so any derived query results (filters, counts) stored in the volatile hash may also be stale.
getAll — read a collection (two modes)
Default key — all documents
controller.getAll(usersCollection, User.serializer()) {
find().toList()
}
EXISTS "myApp:users"
→ hit: HGETALL "myApp:users"
filter out __kache_empty__ sentinel
deserialise each value → return List<User>
→ miss: run lambda (MongoDB find().toList())
HSET "myApp:users" { "abc123": "{...}", "def456": "{...}", ... }
return List<User>
Each document is its own field. This means a set for one user only updates one field, and a remove only deletes one field — the rest of the cached collection is untouched.
The empty sentinel: if MongoDB returns an empty list, writing nothing leaves the hash non-existent. The next getAll would see EXISTS = false and query MongoDB again forever. Instead, HSET "myApp:users" "__kache_empty__" "1" is written. Now EXISTS returns true, HGETALL returns the sentinel, it gets filtered out, and the caller gets [] without touching MongoDB.
Custom key — filtered or paginated queries
controller.getAll(
usersCollection, User.serializer(),
cacheKey = "users:role:admin",
) {
find(Filters.eq("role", "admin")).toList()
}
A custom cacheKey triggers a completely different code path — the result is stored inside the volatile hash, not as its own Redis key:
HGET "myApp:users:volatile" "users:role:admin"
→ hit: deserialise JSON array → return List<User>
→ miss: run lambda (MongoDB filtered find)
HSET "myApp:users:volatile" "users:role:admin" "[{...},{...}]"
return List<User>
Because the result lives in the volatile hash, every write operation's DEL "myApp:users:volatile" invalidates it automatically. You never manually track which cached queries need clearing when a document changes.
set — write one document
controller.set(usersCollection, User.serializer()) {
findOneAndUpdate(filter, update, options)
}
run lambda (MongoDB findOneAndUpdate) → updatedUser
HSET "myApp:users" "abc123" "{...updated json...}"
DEL "myApp:users:volatile"
return updatedUser
The lambda runs first. Its return value is what gets cached — so the cache always reflects exactly what the database confirmed was written, not what you intended to write.
invalidateVolatiles = false skips the DEL step for writes that cannot affect any cached query result (e.g. updating a lastSeen timestamp while your volatile results are role-count queries).
setAll — write multiple documents
controller.setAll(usersCollection, User.serializer()) {
if (insertMany(users).wasAcknowledged()) users else emptyList()
}
run lambda → List<User>
if list is empty:
HSET "myApp:users" "__kache_empty__" "1"
else:
HSET "myApp:users" { "abc123": "{...}", "def456": "{...}", ... }
DEL "myApp:users:volatile"
return true
getVolatile — cache a computed value
For anything that isn't a simple document list — counts, aggregates, paginated slices:
controller.getVolatile("users:count", usersCollection, Long.serializer()) {
countDocuments()
}
HGET "myApp:users:volatile" "users:count"
→ hit: deserialise → return 42L
→ miss: run lambda (MongoDB countDocuments())
HSET "myApp:users:volatile" "users:count" "42"
return 42L
Same volatile hash, same automatic invalidation on any write to the collection.
remove — delete one document
controller.remove(userId, usersCollection) {
deleteOne(Filters.eq("_id", userId)).wasAcknowledged()
}
run lambda (MongoDB deleteOne)
→ false: nothing changes in Redis
→ true:
HDEL "myApp:users" "abc123" ← remove just this field
DEL "myApp:users:volatile" ← clear all volatile results
return true
Only the specific document field is evicted. All other cached documents in the collection remain.
removeAll — delete many documents
controller.removeAll(usersCollection) {
deleteMany(filter).deletedCount > 0
}
run lambda (MongoDB deleteMany)
→ false: nothing changes in Redis
→ true:
DEL "myApp:users" ← drop entire hash (atomic, one command)
DEL "myApp:users:volatile"
return true
DEL on the primary hash is one atomic command — no race window between evicting individual fields. The trade-off is that it evicts all cached documents for the collection, including ones that weren't deleted. The next getAll will be a cache miss and re-warms the collection from MongoDB.
cacheEnabled — the bypass switch
Every internal method starts with:
if (!cacheEnabled()) return getData() // or setData() / deleteData()
cacheEnabled is a () -> Boolean evaluated fresh on every call. When it returns false, your lambda runs directly and Redis is never touched. Wire it to a feature flag, environment variable, or per-request context without rebuilding the controller.
Full request flow (visual)
Your code MongoKacheController Redis MongoDB
│ │ │ │
│── get(id) ───────────▶│ │ │
│ │── HGET primary ──▶│ │
│ │◀── hit: json ──────│ │
│◀── User ──────────────│ │ │
│ │ │ │
│── get(id) ───────────▶│ │ │
│ │── HGET primary ──▶│ │
│ │◀── miss: null ─────│ │
│ │───────────────────────── find() ──────▶│
│ │◀───────────────────────── User ────────│
│ │── HSET primary ──▶│ │
│ │── DEL volatile ───▶│ │
│◀── User ──────────────│ │ │
│ │ │ │
│── set() ─────────────▶│ │ │
│ │──────────────────────── update() ─────▶│
│ │◀────────────────────── updatedUser ────│
│ │── HSET primary ──▶│ │
│ │── DEL volatile ───▶│ │
│◀── updatedUser ───────│ │ │