Avdar is a Kotlin Multiplatform (KMP) caching library that provides a type-safe API, a small DSL for cache configuration, and built-in stale-while-revalidate (SWR) behavior. It offers L1 (in-memory) and L2 (persistent) caching, request deduplication, and cache-control integration to keep data fast and resilient across platforms.
There is a Mongolian saying: "It's better to look in the chest than ask around" (ΠΠΉΠ»Π°Π°Ρ ΡΡΡΡ ΡΡΡ Π°Π²Π΄ΡΠ°Π° ΡΡΠ΄Π°Π»). That saying captures the essence of this library: looking in your own "chest" (cache) before making a network request.
- Type-safe caching with
@Serializablemodels and CBOR encoding viakotlinx.serialization. - Stale-while-revalidate to serve cached data immediately while refreshing in the background.
- L1 + L2 caching with an in-memory LRU store layered on a persistent store you provide.
- Request deduplication so concurrent fetches for the same key share one inflight request.
- Cache-control integration with helpers for HTTP headers.
- KMP-ready for JVM, Android, iOS, and Linux targets.
- JVM
- Android
- iOS (x64, arm64, simulator arm64)
- Linux (x64)
Each cache entry has two timestamps:
- freshUntil: before this time, the entry is fresh and returned immediately.
- stalesAt: after freshUntil but before stalesAt, the entry is stale and can be served while a background refresh runs (SWR window).
- expired: after stalesAt, the entry is expired and must be fetched again.
Avdar layers two cache tiers:
- L1:
LruMemoryStorefor fast in-memory access with LRU eviction. - L2: a persistent
Storeimplementation that you provide (for example, Room, SQLDelight, or file-based storage).
You choose between public and private stores per entity. Private stores are intended for sensitive data (often encrypted and/or with hashed keys).
Entity cache policies (TTL and SWR durations) can be persisted via PolicyStore. Call persistPolicies() after registration to store policy metadata for later inspection or tooling.
dependencies {
implementation("im.nmds.avdar:avdar:1.0.0")
}@Serializable
data class Region(val id: String, val name: String)
val avdar = Avdar(
stores = AvdarStores(
publicStore = publicStoreImpl,
privateStore = privateStoreImpl,
policyStore = policyStoreImpl,
memoryStore = LruMemoryStore()
)
)
avdar.entity(Region::class) {
store = StoreType.Public
ttl = 15.minutes
swr = 7.days
}
// Persist policies after registering all entities
avdar.persistPolicies()
// Fetch with SWR
val region = avdar.fetch<Region>(key = "region-123") {
val response = api.getRegion("region-123")
putAndReturn(cacheControl = response.cacheControl) { response.value }
}avdar.entity(User::class) {
store = StoreType.Private
ttl = 10.minutes
swr = 1.hours
typeName = "users"
}val user = avdar.fetch<User>(key = "user-42") {
val response = api.getUser("user-42")
putAndReturn(cacheControl = response.cacheControl) { response.value }
}Fetch order:
- Fresh L1 cache
- Fresh L2 cache (promoted to L1)
- Stale cache (SWR) with background refresh
- Network fetch (deduplicated across callers)
val fresh = avdar.fetch<User>(key = "user-42", forceRefresh = true) {
val response = api.getUser("user-42")
putAndReturn { response.value }
}forceRefresh = true bypasses freshness checks but still deduplicates concurrent requests and falls back to stale data on error.
val fresh = avdar.refetch<User>(key = "user-42") {
val response = api.getUser("user-42")
putAndReturn { response.value }
}refetch is a dedicated helper that always executes the fetch block and updates the cache.
val cached = avdar.get<User>("user-42")
val exists = avdar.exists<User>("user-42")
val fresh = avdar.isFresh<User>("user-42")avdar.put("user-42", user)
avdar.invalidate<User>("user-42")Avdar includes a small helper to parse HTTP cache directives and ETags:
val cacheControl = CacheControl.parse(
headerValue = response.headers["Cache-Control"],
etag = response.headers["ETag"]
)Use cacheControl.maxAge, cacheControl.staleWhileRevalidate, cacheControl.noStore, and cacheControl.etag inside putAndReturn or put to override default TTL/SWR behavior.
You provide two implementations:
- Public Store: normal cache data.
- Private Store: sensitive data (often encrypted at rest).
Each store implements the Store interface:
interface Store {
suspend fun getEntry(type: String, key: String): CacheEntry?
suspend fun setEntry(entry: CacheEntry)
suspend fun deleteEntry(type: String, key: String)
suspend fun deleteStaleEntries(currentTimeMillis: Long)
suspend fun deleteAllEntries()
}A PolicyStore implementation is optional but recommended for persistable TTL/SWR metadata.
LruMemoryStore provides an in-memory L1 cache with LRU eviction. The default target size is 10 MB, and you can adjust it:
val memoryStore = LruMemoryStore(targetSize = 25L * 1024 * 1024) // 25 MBAvdar is silent by default. To enable logging, pass an AvdarLogger implementation to the constructor:
val avdar = Avdar(
stores = AvdarStores(
publicStore = publicStoreImpl,
privateStore = privateStoreImpl,
policyStore = policyStoreImpl,
memoryStore = LruMemoryStore()
),
logger = object : AvdarLogger {
override fun d(message: () -> String) = println("[DEBUG] ${message()}")
override fun i(message: () -> String) = println("[INFO] ${message()}")
override fun w(message: () -> String) = println("[WARN] ${message()}")
override fun w(throwable: Throwable, message: () -> String) = println("[WARN] ${message()}: $throwable")
override fun e(message: () -> String) = println("[ERROR] ${message()}")
override fun e(throwable: Throwable, message: () -> String) = println("[ERROR] ${message()}: $throwable")
}
)You can integrate with any logging framework (Kermit, SLF4J, Logback, etc.) by implementing AvdarLogger.
Entities must be serializable via kotlinx.serialization (CBOR). Make sure your models are annotated with @Serializable and that you include the serialization plugin.
./gradlew :library:jvmTestApache-2.0