Important
Kacheable is production-usable, but it is still a 0.x library. The typed cache-key API is the intended direction and should be safe to try in real applications, while minor source-level refinements may still happen before 1.0 as community feedback comes in.
Note
Cached values currently use Kotlinx Serialization JSON by default, so stored value types should be @Serializable unless you provide a custom codec.
Kacheable is a Kotlin caching library for expensive function and repository results. You keep the domain call as a lambda, then attach the cache identity, storage shape, miss behavior, refresh rules, and cold-start recovery around it.
The core idea is:
Keep the lambda as the source of truth. Make everything around it typed, explicit, and reusable.
That means call sites can stay close to the work they are doing:
cache(productCardCache(productId)) {
catalogClient.fetchProductCard(productId)
}When that result becomes costly enough to deserve more care, the same lambda can opt into fallback, background loading, refresh, single-flight, and snapshots without turning the repository method into cache plumbing.
Define reusable key parts, then compose them into cache keys:
val songId = keyPart<Int>("songId")
val artistId = keyPart<Int>("artistId")
val accountId = keyPart<Int>("accountId")
val locale = matchableKeyPart<String>("locale")
val page = keyPart<Page>("page", Page::offset, Page::limit)
val songCache = cacheKey(
"song",
returns<Song>(), // 1
key = exact(songId), // 2
)
val artistPagesCache = cacheKey(
"artist-pages",
returns<List<Song>>(), // 3
key = partitioned(
partition = artistId, // 4
key = page + locale, // 5
),
)
suspend fun song(songIdValue: Int): Song =
cache(songCache(songIdValue)) {
repository.song(songIdValue)
}
suspend fun artistPage(artistIdValue: Int, pageValue: Page, localeValue: String): List<Song> =
cache(artistPagesCache(artistIdValue, pageValue, localeValue)) {
repository.artistPage(artistIdValue, pageValue, localeValue)
}
suspend fun invalidateArtistLocale(artistIdValue: Int, localeValue: String) {
cache.invalidate(artistPagesCache.matching(artistIdValue, locale(localeValue))) // 6
}returns<Song>()says what one cache lookup returns. The return type belongs to the key definition, not to each call site.exact(songId)means oneSongis identified directly by onesongId.returns<List<Song>>()is still one cached value. Collections are not split into many entries unless you model the cache that way.partition = artistIdgroups related entries so they can be invalidated together.page + localecomposes two key parts into the entry key inside that artist partition.matching(...)can invalidate every entry in one artist partition whose matchable key part has that locale.
Partition related values when you want narrow invalidation:
val artistSongCache = cacheKey(
"artist-song",
returns<Song>(), // 1
key = partitioned(
partition = artistId, // 2
key = songId, // 3
),
)
cache(artistSongCache(artistIdValue, songIdValue)) {
repository.artistSong(artistIdValue, songIdValue)
}
cache.invalidate(artistSongCache(artistIdValue, songIdValue)) // 4
cache.invalidate(artistSongCache.partition(artistIdValue)) // 5
cache.invalidate(artistSongCache.all()) // 6- The cache still returns one
Songper lookup. - The partition is the outer grouping key.
- The entry key identifies one cached result inside that partition.
- Exact invalidation removes one cached result.
- Partition invalidation removes every result under one artist.
- Whole-cache invalidation removes every
artist-songresult across all artists.
- Raw cache API for simple exact keys
- Typed
cacheKey(...)API for result-first cache definitions - Exact values, indexed values, boolean membership, and enum membership
- Exact, partition, matchable, and whole-cache invalidation refs
- Single-partition caches for top-level paginated result families
- Nullable results and nullable key parts
- Conditional writes with
cacheIf - Typed miss policies for fallback and background loading
- Blocking and suspending interfaces
- In-memory, Redis/Lettuce, and no-op stores
- Per-cache expiry configuration
- Opt-in loader resilience for cold-cache pressure
- Durable snapshots for indexed/hash-style cache families
- Custom cache naming strategies
Most application caches start as a small wrapper around a function:
suspend fun productCard(id: ProductId): ProductCard =
cache(productCardCache(id)) {
catalogClient.fetchProductCard(id)
}The hard part arrives later:
- the key has several parts and should be type-safe;
- some values should not be stored;
- a miss should return a cheap fallback while the real value loads;
- a cached value may be stale but still better than blocking the caller;
- a cold Redis start should not force thousands of expensive computations to rerun at once.
Kacheable keeps those concerns around the lambda instead of replacing the lambda with a cache-specific repository API.
cacheKey(...) binds three things together:
- The cache name.
- The result type returned by one cache lookup.
- The key shape that identifies that result.
val artistSongsCache = cacheKey(
"artist-songs",
returns<List<Song>>(), // 1
key = exact(artistId), // 2
)Read this as:
Cache one
List<Song>for eachartistId.
The result type is not a storage instruction. List<Song>, Set<Int>, and Map<Int, Song> are ordinary cached values unless you model the cache as partitioned.
- One lookup returns the whole list.
artistIddirectly identifies that one list.
val artistPageCache = cacheKey(
"artist-page",
returns<List<Song>>(), // 1
key = partitioned(
partition = artistId, // 2
key = page, // 3
),
)Read this as:
Cache one
List<Song>for eachpageentry inside oneartistIdpartition.
This is the point where Kacheable can store related entries together and invalidate them together.
- One lookup still returns a whole
List<Song>. artistIdis the grouping key.pageidentifies one list inside the artist partition.
Use exact(...) when the key points directly at one cached result.
val appSettingsCache = cacheKey(
"app-settings",
returns<AppSettings>(), // 1
key = exact(), // 2
)
val artistSongsCache = cacheKey(
"artist-songs",
returns<List<Song>>(), // 3
key = exact(artistId), // 4
)Collections are ordinary values. returns<List<Song>>(), returns<Set<Int>>(), and returns<Map<Int, Song>>() each describe one cached result unless you choose a partitioned key.
AppSettingsis one cached result.exact()is for no-argument values.- The whole song list is one cached result.
artistIddirectly identifies that one list.
Use partitioned(partition = ..., key = ...) when one domain value owns many cached entries.
val artistPagesCache = cacheKey(
"artist-pages",
returns<List<Song>>(), // 1
key = partitioned(
partition = artistId, // 2
key = page, // 3
),
)Read it as: one List<Song> for each page key inside one artistId partition.
The refs tell you what can be invalidated:
cache.invalidate(artistPagesCache(artistIdValue, pageValue)) // 4
cache.invalidate(artistPagesCache.partition(artistIdValue)) // 5
cache.invalidate(artistPagesCache.all()) // 6- Each page lookup returns one list.
- The artist is the partition.
- The page is the entry key inside the partition.
- Removes one artist page.
- Removes every page for one artist.
- Removes all pages for every artist.
Use partitioned(key = ...) when there is no natural outer partition, but the cache should still be stored as one indexed family:
val newestVideosCache = cacheKey(
"newest-videos",
returns<List<VideoId>>(), // 1
key = partitioned(key = page), // 2
)
cache.invalidate(newestVideosCache.partition()) // 3That is useful for paginated top-level results: each page is still one logical result, but clearing the whole family does not require a raw key-prefix delete.
- Each lookup returns one page of ids.
- There is no outer partition value, but the pages still belong to one cache family.
partition()clears that whole family.
Use matchableKeyPart(...) when a part of the inner key should be available for scoped invalidation.
val locale = matchableKeyPart<String>("locale")
val localizedPagesCache = cacheKey(
"localized-pages",
returns<PageResult>(), // 1
key = partitioned(
partition = artistId, // 2
key = page + locale, // 3
),
)
cache.invalidate(localizedPagesCache.matching(artistIdValue, locale("he"))) // 4Matching is key matching inside the cache structure, not value search. It is scoped to a partition or cache family; Kacheable does not do keyspace-wide wildcard searches for typed matchable invalidation.
- One lookup returns one page result.
- Matching is scoped to one artist partition.
page + localecomposes the entry key; onlylocaleis matchable because it was defined withmatchableKeyPart.- Removes all entries for
locale = "he"inside the selected artist partition.
Only matchableKeyPart(...) values can be passed to matching(...), so this kind of broad invalidation has to be opted into on the key part itself.
val locale = matchableKeyPart<String>("locale")
val device = matchableKeyPart<String>("device")
val pageCache = cacheKey(
"artist-pages",
returns<SongPage>(), // 1
key = partitioned(
partition = artistId, // 2
key = page + locale + device, // 3
),
)
cache.invalidate(pageCache.matching(artistIdValue, locale("he"))) // 4
cache.invalidate(pageCache.matching(artistIdValue, locale("he"), device("mobile"))) // 5Because matching needs hash-style field matching, auto() uses indexed value storage when a partitioned key has matchable entry parts, even if the result type is Boolean or an enum.
- The entry value is still one
SongPage. - Matching stays inside one artist partition.
- A composed entry key may contain multiple matchable parts.
- Removes all pages for one locale in the partition.
- Removes only pages matching both locale and device.
With storage = auto(), partitioned Boolean results use set-backed membership storage:
val artistFollowCache = cacheKey(
"artist-follow",
returns<Boolean>(), // 1
key = partitioned(
partition = artistId, // 2
key = accountId, // 3
),
)
cache(artistFollowCache(artistIdValue, accountIdValue)) {
repository.isFollowing(artistIdValue, accountIdValue)
}- The public result is still a
Boolean. - The artist groups all account follow states.
- Under
auto(), Kacheable can store account ids in membership sets instead of serialized Boolean values.
Partitioned enum results use enum membership storage:
enum class Reaction { Like, Dislike, None }
val reactionCache = cacheKey(
"song-reaction",
returns<Reaction>(), // 1
key = partitioned(
partition = songId, // 2
key = accountId, // 3
),
)The caller still gets a Boolean or Reaction; the set layout is only the storage plan.
- The public result is still a
Reaction. - The song groups all account reactions.
- Under
auto(), Kacheable can store account ids in enum classification sets.
cacheIf still applies to newly computed results:
cache(followCache(artistIdValue, accountIdValue), cacheIf = { it }) { // 1
repository.isFollowing(artistIdValue, accountIdValue)
}- The result is returned either way, but only
truevalues are written.
For membership caches, prefer membershipStorage(cacheFalse = false) when the policy is specifically “do not cache false results”:
val followCache = cacheKey(
"artist-follow",
returns<Boolean>(),
key = partitioned(artistId, accountId),
storage = membershipStorage(cacheFalse = false), // 1
)- This expresses the same policy at the storage-plan level, which is clearer for Boolean membership caches.
Storage defaults to auto().
cacheKey(
"song",
returns<Song>(),
key = exact(songId),
storage = auto(), // 1
)
cacheKey(
"song",
returns<Song>(),
key = exact(songId),
storage = exactValueStorage(), // 2
)
cacheKey(
"follow",
returns<Boolean>(),
key = partitioned(artistId, accountId),
storage = indexedValueStorage(), // 3
)
cacheKey(
"follow",
returns<Boolean>(),
key = partitioned(artistId, accountId),
storage = membershipStorage(cacheFalse = false), // 4
)
cacheKey(
"reaction",
returns<Reaction>(),
key = partitioned(songId, accountId),
storage = enumMembershipStorage<Reaction>(), // 5
)Use overrides when you need a specific storage behavior. For example, force indexedValueStorage() if a partitioned Boolean should be serialized as an indexed value rather than stored as membership.
auto()is the default and usually the right choice.exactValueStorage()is only for exact keys.indexedValueStorage()forces serialized values inside a partition, even forBoolean.membershipStorage(cacheFalse = false)stores true membership and skips false results.enumMembershipStorage<Reaction>()makes enum classification storage explicit.
auto() currently resolves like this:
| Key shape | Result type | Storage |
|---|---|---|
exact(...) |
any result | one serialized value |
partitioned(...) |
Boolean, no matchable entry parts |
membership sets |
partitioned(...) |
enum, no matchable entry parts | enum classification sets |
partitioned(...) |
any other result | indexed/hash values |
partitioned(...) |
any result with matchable entry parts | indexed/hash values |
Overrides are intentionally type-limited. For example, exactValueStorage() belongs to exact keys, while membershipStorage() belongs to partitioned Boolean keys.
Nullable results are allowed:
val optionalSongCache = cacheKey(
"optional-song",
returns<Song?>(), // 1
key = exact(songId),
)Nullable key parts are positional values, not omitted values:
val filter = keyPart<ArtistFilter?>("filter") // 2
val sort = keyPart<ArtistSort?>("sort") // 3
val artistsCache = cacheKey(
"artists",
returns<List<Artist>>(), // 4
key = exact(filter + sort + page), // 5
)- Nullable results can be cached when the cache config has a null placeholder.
filter = nullcan be a real key value, such as “no filter selected”.sort = nullis still positional; it is not omitted from the generated key.- The result is one list of artists.
- Nullable and non-nullable key parts can be composed together.
The default naming strategy renders null key parts as <null>. Customize that with:
val cache = Kacheable(
store = store,
namingStrategy = defaultCacheNamingStrategy(nullKeyPart = "__NULL__"), // 6
)Use nullable key parts when null is a real part of the repository call identity. For example, filter = null can mean “no filter selected”, which is different from omitting the filter from the key.
- The null key placeholder is configurable in the naming strategy.
Kacheable does not add loader coordination by default. Plain cache misses keep the simple behavior: if ten callers miss the same key at the same time, all ten loaders may run.
For expensive loaders, configure resilience globally or per cache:
val cache = Kacheable(
store = redisStore,
defaultResilience = CacheResilienceConfig(
singleFlight = SingleFlightMode.Local, // 1
maxConcurrentLoads = 8, // 2
loadTimeout = 2.seconds, // 3
staleOnTimeout = true, // 4
),
configs = mapOf(
"artist-page" to CacheConfig(
name = "artist-page",
expiryType = ExpiryType.after_write,
expiry = 10.minutes,
resilience = CacheResilienceConfig(
singleFlight = SingleFlightMode.Redis,
maxConcurrentLoads = 3,
),
),
),
)Localruns one loader per cache key per JVM; concurrent callers await the same result.maxConcurrentLoadslimits how many different cold keys can load for that cache at once.loadTimeoutbounds the loader path. It does not change Redis command timeouts.staleOnTimeoutandstaleOnFailuremay return a previously cached value when one exists.
SingleFlightMode.Redis coordinates across processes with Redis lock keys. It requires a store that supports distributed coordination, such as the Lettuce store. Kacheable fails fast during startup if Redis single-flight is configured against a store that cannot provide it.
Use Redis single-flight for multi-pod cold-cache stampedes. Use Local for a cheaper per-process guard. Keep None for cheap loaders or when duplicate work is acceptable.
Most cache calls can stay as simple read-through lambdas:
cache(songCache(songIdValue)) {
repository.song(songIdValue)
}When a real miss should behave differently, use CacheMissPolicy at the call site:
val productId = keyPart<String>("productId")
val productCardCache = cacheKey(
"product-cards",
returns<ProductCard?>(),
key = partitioned(key = productId),
)
cache(
productCardCache(productIdValue),
missPolicy = CacheMissPolicy.loadInBackground(
fallback = { ProductCard.placeholder(productIdValue) }, // 1
),
storeResultIf = { it != null }, // 2
) {
catalogClient.fetchProductCard(productIdValue) // 3
}loadInBackgroundreturns the fallback immediately. The fallback is not stored.storeResultIfonly decides whether the lambda result is cached. It never changes what the caller receives.- The lambda remains the source of truth and runs in the background after the fallback is returned.
Use load(fallbackOnFailure = ...) when callers should wait for the real value, but you still have
a safe degraded response for errors or timeouts:
cache(
priceQuoteCache(productIdValue),
missPolicy = CacheMissPolicy.load(
fallbackOnFailure = { error -> PriceQuote.unavailable(productIdValue, error.message) },
),
) {
pricingClient.quote(productIdValue)
}The fallback is returned only when the lambda fails. Successful lambda results are still the only values considered for storage.
Available miss policies:
| Policy | Behavior |
|---|---|
CacheMissPolicy.load() |
Normal read-through caching. Run the lambda in the request path and return its result. |
CacheMissPolicy.load(fallbackOnFailure = ...) |
Run the lambda in the request path and return a fallback only if it fails or times out. |
CacheMissPolicy.loadInBackground(fallback = ...) |
Return fallback immediately, then run the lambda in the background. |
Storage decisions are separate from miss behavior:
cache(
expensiveCache(id),
missPolicy = CacheMissPolicy.load(),
refreshPolicy = CacheRefreshPolicy.refreshIf(inBackground = true) { cached -> cached.isStale },
storeResultIf = { result -> result.isStable },
) { previous ->
repository.loadExpensiveValue(id, previous)
}CacheRefreshPolicy.neverRefresh() returns present cached values normally. refreshIf(...) reruns
the lambda only when a cached value is considered stale.
cache(
productCardCache(productIdValue),
missPolicy = CacheMissPolicy.loadInBackground(
fallback = { ProductCard.placeholder(productIdValue) },
),
refreshPolicy = CacheRefreshPolicy.refreshIf(inBackground = true) { cached ->
cached.generatedAt < clock.now() - 30.minutes
},
storeResultIf = { result -> result.isUsable },
) { previous ->
catalogClient.fetchProductCard(
id = productIdValue,
previousVersion = previous?.version,
)
}On a true miss, previous is null. On a refresh, previous is the cached value that triggered the
refresh. With inBackground = true, the caller receives the cached value immediately while the lambda
refreshes it for later callers. Refresh failures keep the previous cached value.
The existing cacheIf overload remains available:
cache(expensiveCache(id), cacheIf = { it.isStable }) {
repository.loadExpensiveValue(id)
}It maps to normal read-through loading with storeResultIf = cacheIf.
The three knobs are intentionally separate:
| Knob | Question it answers |
|---|---|
CacheMissPolicy |
What happens when no cached value exists? |
CacheRefreshPolicy |
What happens when a cached value exists but may be stale? |
storeResultIf / cacheIf |
Should the lambda result be written to storage? |
Snapshots are opt-in durable warm-cache snapshots for expensive cache families. They are useful when a cache is too expensive to recreate after a cold Redis start, but the loader lambda should remain the source of truth.
val productId = keyPart<String>("productId")
val productCardCache = cacheKey(
"product-cards",
returns<ProductCard?>(),
key = partitioned(key = productId),
storage = indexedValueStorage(), // 1
)
val cache = Kacheable(
store = redisStore,
snapshotStore = S3CacheSnapshotStore(
bucket = "app-cache-snapshots",
prefix = "catalog/product-cards/v1",
readObject = { bucket, key -> s3.getObjectBytes(bucket, key) },
writeObject = { bucket, key, bytes -> s3.putObjectBytes(bucket, key, bytes) },
),
configs = mapOf(
"product-cards" to CacheConfig(
name = "product-cards",
snapshot = persistentSnapshot(
restore = SnapshotRestore.BackgroundWithOnDemandChunks, // 2
flushInterval = 15.minutes, // 3
retention = SnapshotRetention.LatestAndPrevious, // 4
),
),
),
)indexedValueStorage()stores related entries as an indexed family, which can be exported and restored as chunks.- Background restore starts immediately, and an early request miss can restore just the relevant snapshot chunk before running the miss policy.
- Periodic flush exports the hot cache to the configured snapshot store.
- Keeping latest and previous lets restore fall back when the latest snapshot is missing or corrupt.
Snapshots are intentionally a warm-cache mechanism, not a second database. A restored entry behaves like any other cached value: refresh policies may refresh it, expiry may later remove it, and misses still go through the configured miss policy.
Restore modes:
| Mode | Behavior |
|---|---|
SnapshotRestore.Blocking |
Restore before Kacheable(...) returns. |
SnapshotRestore.Background |
Start restoring immediately in the background. |
SnapshotRestore.BackgroundWithOnDemandChunks |
Restore in the background, and on a miss try the relevant chunk before running the miss policy. |
Retention modes:
| Mode | Behavior |
|---|---|
SnapshotRetention.LatestOnly |
Write and restore only the latest snapshot slot. |
SnapshotRetention.LatestAndPrevious |
Rotate latest to previous on flush, and fall back to previous when latest is missing or corrupt. |
The raw API remains available for low-level or migration cases:
cache("user", userId) { // 1
repository.user(userId)
}
cache.invalidate(rawCacheEntry("user", userId)) // 2
cache.invalidate(rawCache("legacy-family")) // 3Prefer typed cache refs for new code because they preserve the cache result type and storage plan through invalidation.
- Raw cache calls are string-keyed and do not carry a typed cache definition.
rawCacheEntry(...)targets one known legacy entry.rawCache(...)targets a whole legacy cache family.
The default naming strategy receives exact and partitioned keys differently:
val songCache = cacheKey(
"song",
returns<Song>(),
key = exact(songId), // 1
)
val artistPageCache = cacheKey(
"artist-page",
returns<Page>(),
key = partitioned(artistId, page), // 2
)For songCache(7), songId is passed as primary params.
For artistPageCache(3, Page(0, 20)), artistId is passed as primary params and page is passed as secondary params. Redis/hash-like stores use that split to keep all pages for one artist under one partition key.
Custom naming strategies can change the generated strings while keeping that exact/partition split.
- Exact keys pass all key values as primary params.
- Partitioned keys pass partition values as primary params and entry-key values as secondary params.
See docs/cache-key.md for the full cache-key guide, including blocking APIs, custom naming strategies, matchable invalidation, miss policies, snapshots, and storage planning details.