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 wrapping computations behind typed cache keys.
The core idea is:
One cache key describes one logical cached result. Storage is an optimization plan.
That means the call site talks about what the repository returns, while Kacheable can choose an exact value, hash/indexed value, boolean membership set, or enum classification set behind the scenes.
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 - Blocking and suspending interfaces
- In-memory, Redis/Lettuce, and no-op stores
- Per-cache expiry configuration
- Custom cache naming strategies
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.
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, and storage planning details.