Skip to content

Bluetape4k Cache Part 1: Why the Cache Module Looks This Way

Bluetape4k cache module workbench with robot workers assembling cache blocks
A cache is a fast drawer. The tricky part is that real systems have more than one drawer.

This is Part 1 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.

bluetape4k-cache starts from a familiar question: “can we just wrap a Map?” Production usually makes that question bigger than it first looked.

Local caches are fast. Redis shares state. JCache gives us a standard API. Kotlin coroutine code needs a suspend path. When a remote cache is briefly unstable, retry and fallback also matter.

Wire those pieces by hand in every application, and cache code eventually takes more space than domain code. At that point, caching is no longer just a performance optimization. It is operational complexity.

That is why the Bluetape4k Cache module does not start as a loose collection of cache implementations. It first defines a shared language for cache behavior, then plugs provider-specific implementations into that language.

Bluetape4k cache module map from cache-core to local providers, memoizers, resilience, and distributed providers
cache-core defines the common contract, and provider modules share the same usage model.

The starting point is cache-core: the layer that lets application code read, write, invalidate, and handle failures in a similar shape regardless of provider.

AreaRole
JCache helpersJSR-107 style cache configuration and provider initialization
SuspendCacheA suspend-friendly cache contract for coroutine code
MemoizerCaches function results across sync, async, and suspend paths
NearCache interfacesDescribes an L1 local + L2 backing cache structure
Resilient decoratorAdds retry, fallback, and statistics outside the cache implementation

Provider modules sit behind that contract and talk to the actual storage systems.

ModuleRole
cache-coreCaffeine, Cache2k, Ehcache, JCache, and common NearCache APIs
cache-lettuceRedis/Lettuce JCache and RESP3 NearCache
cache-redissonRedisson JCache and RLocalCachedMap NearCache
cache-hazelcastHazelcast JCache and IMap NearCache

Full source: bluetape4k-projects/cache

A local cache lives inside the JVM. There is no network round trip, so it is fast and simple to configure.

import io.bluetape4k.cache.caffeine.caffeine
import java.time.Duration
val cache = caffeine {
maximumSize(10_000)
expireAfterWrite(Duration.ofMinutes(10))
}.build<String, Any>()

Full source: cache-core README

The limitation is just as clear. A local cache only knows its own JVM. If there are two application instances, there are two drawers. Assuming one drawer knows what the other drawer contains is how incident reports become a literary genre.

SuspendCache: A Contract That Fits Coroutine Code

Section titled “SuspendCache: A Contract That Fits Coroutine Code”

In Kotlin services, a cache miss often reads from a database or calls a remote API. If that path is suspend, the cache API should be suspend too.

import io.bluetape4k.cache.jcache.CaffeineSuspendJCache
val cache = CaffeineSuspendJCache<String, String>("sessions")
cache.put("token:1", "user-1")
val userId = cache.get("token:1")

Related source: CaffeineSuspendJCache.kt

Memoizer: Turning Function Results into Cache Entries

Section titled “Memoizer: Turning Function Results into Cache Entries”

A memoizer lifts this property into the API: “this function often returns the same result for the same input.” Callers keep using a function, while the cache handles repeated work.

import io.bluetape4k.cache.memoizer.suspendMemoizer
val userSummary = suspendMemoizer<Long, String> { userId ->
loadUserSummaryFromDatabase(userId)
}
val summary = userSummary(42L)

Related source: Memoizer.kt, LettuceMemoizer.kt

NearCache: A Fast Drawer and a Shared Warehouse

Section titled “NearCache: A Fast Drawer and a Shared Warehouse”

NearCache combines an L1 local cache with an L2 backing cache. Frequently read values stay near the JVM, while shared state remains in the remote cache.

import io.bluetape4k.cache.LettuceCaches
val cache = LettuceCaches.nearCache<String>(redisClient) {
cacheName = "users"
maxLocalSize = 10_000
recordStats = true
}
cache.put("42", "Debop")
val name = cache.get("42")

NearCacheOperations provides common operations such as get, put, remove, clearLocal, clearAll, and stats. Whether the provider is Lettuce, Redisson, or Hazelcast, application code keeps the same shape.

Related source: NearCacheOperations.kt, LettuceCaches.kt

A cache looks like a performance feature. The moment a remote cache is involved, it also becomes a failure propagation path. If every request is affected when Redis is briefly unstable, the cache did more than make things slower. It became a failure amplifier.

Bluetape4k provides a resilience decorator for that reason.

val resilient = cache.withResilience {
retryMaxAttempts = 5
retryWaitDuration = Duration.ofMillis(200)
getFailureStrategy = GetFailureStrategy.RETURN_FRONT_OR_NULL
}

Related source: ResilientNearCacheDecorator.kt, NearCacheResilienceConfig.kt

The Cache module is trying to keep one problem simple.

  • Use local caches for speed.
  • Use remote caches for shared state.
  • Match suspend APIs to coroutine code.
  • Use NearCache to connect the two worlds.
  • Use resilience so a brief “hold on” does not become a full outage.

Next, we will pull NearCache out on its own: why remote cache alone is often not enough, how Redisson Pub/Sub differs from Lettuce RESP3 CLIENT TRACKING, and what the benchmark numbers say. Numbers can be blunt. That is part of their charm.

Comments

Leave a note or reaction with your GitHub account.