Module cache-on-hand
Cache On Hand
A set of solutions for operating on a global cache. Heavily inspired by:
Platforms: JVM, Android, iOS (x64/arm64/simulator), WASM-JS
Goals
CacheOnHand provides the following:
-
Modularization that allows for taking ONLY what you need
-
No enforcement of UI framework - the barebones are compose agnostic
-
A familiar API for users coming from a react query, or SWR like framework
-
Transactional optimistic cache updates, that can rollback on error. Allowing for a snappy UI experience
-
Tools operating on your cache as a Query, Mutation, InfiniteQuery, or Flow
-
Typed
refetch()andoptimisticUpdater()functions that exist at definition (not at use), and are usable anywhere
Future work
-
Persisting outside the local cache
-
Cache viewer plugin
-
Integration with data fetching frameworks
Modules
Cache On Hand is split into three modules that build on each other. Use only what you need:
| Module | Description | Use when... |
|---|---|---|
| cacheonhand/README.md | Thread-safe reactive cache with TTL | You just need a reactive in-memory cache |
| cacheonhand-attendants/README.md | Query, mutation, flow, and infinite query operations | You want managed data fetching with state tracking |
| cacheonhand-compose/README.md | Compose Multiplatform wrappers | You're building Compose UI |
Each module transitively includes its dependencies — adding cacheonhand-compose gives you everything.
Installation
// build.gradle.kts
dependencies {
// Pick one — each includes its dependencies
implementation("io.github.notoriouscorgi:cacheonhand:<version>") // cache only
implementation("io.github.notoriouscorgi:cacheonhand-attendants:<version>") // cache + operations
implementation("io.github.notoriouscorgi:cacheonhand-compose:<version>") // cache + operations + compose
}
Quick Example
// Define a cache key
data class GetUserInput(val userId: String) : CacheableInput.QueryInput {
override val identifier = "GET /api/users/$userId"
}
// Create a shared cache
val cache = OnHandCache()
// Define a query factory
val getUserFactory = queryFactoryOf<GetUserInput, User, ApiException>(
cache = cache,
) {
withContext(Dispatchers.IO) { input ->
api.getUser(input.userId)
}
}
// Query your data
val queryAndResult = getUserFactory()
// Start listening to results
queryAndResult.result.collect { ... }
// Fetch your query
viewModelScope.launch {
queryAndResult.fetch(GetUserInput(1))
}
// Wrap for Compose
val rememberGetUser = composeQueryFactoryOf(getUserFactory)
// Use in a composable
@Composable
fun UserScreen(userId: String) {
val result = rememberGetUser(input = GetUserInput(userId))
when (result.fetchState) {
FetchState.LOADING -> CircularProgressIndicator()
FetchState.SUCCESS -> Text("Hello, ${result.data?.name}")
FetchState.ERROR -> Text("Error: ${result.error?.message}")
FetchState.IDLE -> {}
}
}
Refetch on Mutation
Refresh query data after a mutation by calling refetch in onSuccess:
val mutation = updateUserFactory.create()
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
getUserFactory.refetch(GetUserInput("123"))
// All active query instances observing this key update automatically
},
)
// Use launch() for fire-and-forget refetches (e.g., refreshing data not currently on screen)
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
scope.launch { getUserFactory.refetch(GetUserInput("123")) }
},
)
Features
-
Queries — fetch data with automatic caching, TTL, and refetch
-
Mutations — write operations with optimistic updates and automatic rollback
-
Flows — subscribe to reactive sources (SSE, WebSockets) with cached emissions
-
Infinite Queries — paginated data with forward/backward navigation
-
Optimistic Updates — instant UI updates that revert on failure
-
Stale-While-Revalidate — show cached data while refetching in the background
-
TTL Eviction — automatic cache entry expiry
-
Thread Safety — per-key mutex locking with deadlock prevention
-
Compose Integration — composable hooks with lifecycle-aware scoping
-
Type-Safe Errors — generic
TErrorparameter instead of rawThrowable -
Factory Pattern — define once, use everywhere
Rules
These assumptions apply across all modules:
-
Cache keys must be data classes — the cache uses HashMap internally. Regular classes without proper
equals/hashCodewill silently create duplicate entries. Always usedata classfor yourCacheableInputimplementations. -
Operations run on
Dispatchers.Default— usewithContext(Dispatchers.IO)inside your query/mutation/flow lambda for network or disk calls. -
Don't use
launch/asyncwithout awaiting inside factory lambdas — the operation must complete sequentially so state transitions (LOADING -> SUCCESS/ERROR) are correct. However,launch/asyncis fine insideonSuccess/onErrorcallbacks — those run after state transitions are complete. -
refetch()throws on failure — unlikefetch()which catches errors and sets ERROR state,refetch()propagates exceptions to the caller. Wrap in try-catch orscope.launch. -
Null data is not an error — a query or flow returning null sets state to SUCCESS with null data, not ERROR. This is intentional for distinguishing "no data exists" from "fetch failed".
Development
Build
./gradlew build
Run Tests
# All modules
./gradlew cacheonhand:jvmTest cacheonhand-attendants:jvmTest cacheonhand-compose:jvmTest
# Single module
./gradlew cacheonhand:jvmTest
./gradlew cacheonhand-attendants:jvmTest
./gradlew cacheonhand-compose:jvmTest
Release
Create a release and publish to Maven Central:
./scripts/release.sh # auto-increments minor version (0.1.0 → 0.2.0)
./scripts/release.sh 1.0.0 # explicit version
Or trigger directly from GitHub Actions (Actions -> Publish -> Run workflow) with an optional version input.
The workflow runs tests first, then publishes all three modules to Maven Central.
Generate Docs
Generates API documentation from source and READMEs into docs/ for GitHub Pages:
./gradlew :dokkaGenerate
License
Licensed under the Apache License, Version 2.0
All modules:
A thread-safe, reactive in-memory cache with TTL support for Kotlin Multiplatform. If you only want transactional optimistic updates, use this alone.
Structured "attendants" to your underlying cache; Attendants provide the core operations for Kotlin Multiplatform — queries, mutations, flows, and infinite queries built on top of cacheonhand.
Compose Multiplatform convenience wrappers for Cache On Hand. Turns query, mutation, flow, and infinite query factories into composable hooks with automatic lifecycle management.