Bluetape4k Cache Part 3: Building Caching Strategies with Near Cache and Exposed

This is Part 3 of the Bluetape4k Cache series. The full series continues with Part 1: cache module overview, Part 2: Near Cache, Part 3: Near Cache + Exposed strategies, and Part 4: workshop examples.
Putting a cache in front of an Exposed repository looks easy at first.
- Check the cache.
- If the value is missing, read the database.
- When saving, update the cache too.
Everyone is calm up to this point. Then the questions arrive: what if the write fails, what about deletes, what about other instances’ local caches, what about batch updates, and what if the read model and write model are not the same? The meeting room suddenly needs another whiteboard.
bluetape4k-exposed splits that problem into repository contracts and cache strategies. It
keeps separate choices for where the cache lives, how it is tied to database writes, and what a
failure should mean.

JdbcCacheRepository is attaching a loader and writer to a Redisson map so read/write contracts change at the map boundary.The Shared Repository Contract
Section titled “The Shared Repository Contract”The base contracts are JdbcCacheRepository and R2dbcCacheRepository. Application code calls
get, put, and invalidate, but the actual behavior follows the selected cache mode and
write mode.
interface JdbcCacheRepository<ID: Any, E: Serializable>: Closeable { val table: IdTable<ID> val cacheName: String val cacheMode: CacheMode val cacheWriteMode: CacheWriteMode
fun ResultRow.toEntity(): E fun extractId(entity: E): ID
fun findByIdFromDb(id: ID): E? fun get(id: ID): E? fun getAll(ids: Collection<ID>): Map<ID, E>
fun put(id: ID, entity: E) fun putAll(entities: Map<ID, E>, batchSize: Int = DEFAULT_BATCH_SIZE)
fun invalidate(id: ID) fun invalidateAll(ids: Collection<ID>) fun clear()}Related source: JdbcCacheRepository.kt, R2dbcCacheRepository.kt
The important detail is that get and put are not plain map operations. They follow repository
policy, and that policy changes performance, consistency, and behavior during failures.
The Implementation Center: MapLoader and MapWriter
Section titled “The Implementation Center: MapLoader and MapWriter”AbstractJdbcRedissonRepository attaches Redisson loader/writer components when it creates an
RMap or RLocalCachedMap.
protected open val mapLoader: EntityMapLoader<ID, E> by lazy { ExposedEntityMapLoader(table) { toEntity() }}
protected val mapWriter: EntityMapWriter<ID, E>? by lazy { when (config.cacheMode) { RedissonCacheConfig.CacheMode.READ_ONLY -> null RedissonCacheConfig.CacheMode.READ_WRITE -> ExposedEntityMapWriter( entityTable = table, updateBody = { stmt, entity -> with(this@AbstractJdbcRedissonRepository) { stmt.updateEntity(entity) } }, batchInsertBody = { entity -> val stmt = this with(this@AbstractJdbcRedissonRepository) { stmt.insertEntity(entity) } }, writeMode = config.writeMode, ) }}Full source: JdbcCacheRepository.kt, AbstractJdbcRedissonRepository.kt
That is why get can look like a map lookup while a cache miss loads from the database through
the loader. Likewise, put can look like a cache write while WRITE_THROUGH persists through
the writer immediately, and WRITE_BEHIND queues persistence asynchronously. This differs from
cache-aside code where the application manually sequences “save DB, then cache.put.”
Read-through
Section titled “Read-through”Read-through fills the cache through EntityMapLoader when a cache miss occurs. exposed-workshop
chapter 11 shows this path with UserCredentialsCacheRepository.
@Repositoryclass UserCredentialsCacheRepository( redissonClient: RedissonClient,) : AbstractJdbcRedissonRepository<String, UserCredentialsRecord>( redissonClient = redissonClient, config = RedissonCacheConfig.READ_ONLY_WITH_NEAR_CACHE.copy( name = "exposed:user-credentials", ),) { override val table = UserCredentialsTable override fun ResultRow.toEntity() = toUserCredentialsRecord() override fun extractId(entity: UserCredentialsRecord): String = entity.id}Full source: UserCredentialsCacheRepository.kt, UserCredentialsCacheRepositoryTest.kt
It fits read-heavy data. The first request reads from the database; later requests hit the cache. The write path still needs correct invalidation. Otherwise yesterday’s cache and today’s database start telling different stories.
Write-through
Section titled “Write-through”Write-through sends put and putAll through a writer-backed Redisson map, and
ExposedEntityMapWriter applies the same operation to the database. UserCacheRepository in
exposed-workshop chapter 11 combines read-through and write-through.
@Repositoryclass UserCacheRepository( redissonClient: RedissonClient,) : AbstractJdbcRedissonRepository<Long, UserRecord>( redissonClient = redissonClient, config = RedissonCacheConfig.READ_WRITE_THROUGH_WITH_NEAR_CACHE.copy( name = "exposed:users", ),) { override val table: UserTable = UserTable override fun ResultRow.toEntity(): UserRecord = toUserRecord() override fun extractId(entity: UserRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserRecord) { this[UserTable.username] = entity.username this[UserTable.firstName] = entity.firstName this[UserTable.lastName] = entity.lastName }
override fun UpdateStatement.updateEntity(entity: UserRecord) { this[UserTable.username] = entity.username this[UserTable.firstName] = entity.firstName this[UserTable.lastName] = entity.lastName }}Related example: UserCacheRepository.kt, UserCacheRepositoryTest.kt
The benefit is fresh reads right after writes. The cost is that cache write work enters write latency. Writes are not free; the invoice arrives as latency.
Write-behind
Section titled “Write-behind”Write-behind uses the same writer, but Redisson’s write mode is WRITE_BEHIND. put updates
the cache first, and database writes are flushed asynchronously through a queue.
@Repositoryclass UserEventCacheRepository( redissonClient: RedissonClient,) : AbstractJdbcRedissonRepository<Long, UserEventRecord>( redissonClient = redissonClient, config = RedissonCacheConfig.WRITE_BEHIND_WITH_NEAR_CACHE.copy( name = "exposed:user-events", ),) { override val table: UserEventTable = UserEventTable override fun ResultRow.toEntity(): UserEventRecord = toUserEventRecord() override fun extractId(entity: UserEventRecord): Long = entity.id
override fun BatchInsertStatement.insertEntity(entity: UserEventRecord) { this[UserEventTable.username] = entity.username this[UserEventTable.eventType] = entity.eventType this[UserEventTable.eventTime] = entity.eventTime }}Full source: UserEventCacheRepository.kt, UserEventCacheRepositoryTest.kt
Write-behind can reduce write latency, but it adds design work.
- What happens on queue overflow?
- Should pending writes be flushed on close?
- How are repeated updates to the same key merged?
- How do we prevent stale reads after remove?
- Can retry duplicates conflict with DB upsert behavior?
At this point, cache is no longer a fast Map. It is a small distributed system. Small
distributed systems should fail small. If they fail big, only the name was small.
CacheMode and CacheWriteMode
Section titled “CacheMode and CacheWriteMode”bluetape4k-exposed separates cache location from write strategy.
Related source: CacheMode.kt, CacheWriteMode.kt, LocalCacheConfig.kt
That separation matters. “Where do we cache?” and “When do we write?” are different questions. The same Redis backend can mean very different operational behavior under read-through, write-through, or write-behind.
| Question | Examples |
|---|---|
| cache location | local Caffeine, remote Redis, near cache |
| cache read path | read-through loader |
| write mode | READ_ONLY, WRITE_THROUGH, WRITE_BEHIND |
| API shape | JDBC, suspended JDBC, R2DBC |
| consistency target | strong-ish consistency, eventual consistency, best-effort fallback |
Benchmark Results
Section titled “Benchmark Results”04-benchmark in exposed-workshop chapter 11 compares cache strategies with real numbers. It
uses AverageTime; lower is better.

Related source: CacheStrategyComparisonBenchmark.kt, ReadThroughCacheBenchmark.kt, BENCHMARK-REPORT.md
| Strategy | Workload | Payload | Average |
|---|---|---|---|
NO_CACHE | READ_HEAVY | 256B | 517.520 us/op |
READ_THROUGH | READ_HEAVY | 256B | 94.320 us/op |
WRITE_THROUGH | READ_HEAVY | 256B | 52.103 us/op |
NO_CACHE | WRITE_HEAVY | 256B | 507.766 us/op |
READ_THROUGH | WRITE_HEAVY | 256B | 468.735 us/op |
WRITE_THROUGH | WRITE_HEAVY | 256B | 445.363 us/op |
In read-heavy workloads, READ_THROUGH is about 5.5x faster than the baseline, and
WRITE_THROUGH is about 9.9x faster. In write-heavy workloads, the difference shrinks to around
1.1x. Cache earns money on the read path and receives invoices on the write path.
The single-read read-through benchmark is also worth reading.
| Benchmark | Payload | Average |
|---|---|---|
dbOnlyRead | 256B | 0.001 us/op |
readThroughCacheHit | 256B | 0.003 us/op |
readThroughCacheMiss | 256B | 0.119 us/op |
Hits are cheap. Misses are much more expensive because they invalidate and reload. Near Cache still lives or dies by hit ratio. A cache that hits is a hero; a cache that misses often is a very busy decoration.
Strategy Selection
Section titled “Strategy Selection”| Strategy | Fits Well When | Watch Out For |
|---|---|---|
| Read-through | Reads dominate and writes are rare | Missing invalidation after writes |
| Write-through | Fresh reads right after writes matter | Higher write latency |
| Write-behind | Write bursts need absorption | Queue, retry, flush, stale reads |
| NearCache + read-through | Hot reads are clear | L1 hit ratio and invalidation |
| NearCache + write-behind | Local responsiveness matters strongly | Failure semantics must be documented |
Next, we will put theory down for a moment and move to practical examples in bluetape4k-workshop.
Code is usually kind. We are often just in a hurry when reading it.
Comments
Leave a note or reaction with your GitHub account.