An offline-first, stale-while-revalidate data layer for Kotlin and Android.
JVM & Android today. Aquifer targets Kotlin/JVM and Android (compiled to
JvmTarget.JVM_11). Kotlin Multiplatform is a post-1.0 goal on the roadmap, not yet shipped.
Aquifer sits between your remote API and your UI as the single source of truth for keyed data.
You declare how to fetch and how fresh data must be; Aquifer decides when to serve the
cache, when to hit the network, and keeps every observer of a key in sync — through
process-wide caching, request deduplication, and reactive Flow streams.
val users: Aquifer<UserId, User> = aquifer {
fetcher { id -> api.fetchUser(id) }
freshness { timeToLive = 5.minutes }
memoryCache { maxEntries = 256 }
}
// In a ViewModel: render cached data instantly, refresh in the background.
val state: StateFlow<DataState<User>> =
users.stream(userId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DataState.Loading())// In Compose (aquifer-compose): one line, lifecycle-aware, stream shared across recompositions.
val state by users.collectAsState(userId)
state.value?.let { user -> UserProfile(user) } // keep content visible while refreshing
if (state.isLoading) RefreshIndicator()
(state as? DataState.Failure)?.let { RefreshFailedSnackbar(it.error) }
⚠️ Status: pre-1.0. The API surface is locked by binary-compatibility validation and the Maven Central publishing pipeline is configured, but no release has been tagged yet. Oncev0.1.0ships:dependencies { implementation("io.github.quasarapps:aquifer-core:0.1.0") implementation("io.github.quasarapps:aquifer-compose:0.1.0") // Compose state collection implementation("io.github.quasarapps:aquifer-android:0.1.0") // reconnect/foreground triggers implementation("io.github.quasarapps:aquifer-persistence-file:0.1.0") // disk persistence implementation("io.github.quasarapps:aquifer-persistence-sqldelight:0.1.0") // queryable persistence implementation("io.github.quasarapps:aquifer-okhttp:0.1.0") // ETag/304 revalidation testImplementation("io.github.quasarapps:aquifer-test:0.1.0") // fakeAquifer, FakeClock, settle() }
Every non-trivial Android app ends up re-implementing the same data plumbing:
- "Show cached data immediately, then refresh" — stale-while-revalidate, hand-rolled per screen.
- "Two screens load the same user and fire two identical requests" — no request deduplication.
- "The detail screen edited the user but the list still shows stale data" — no shared source of truth.
- "The network failed but we have perfectly usable data from an hour ago" — no stale-if-error fallback.
Aquifer packages those patterns into one small, focused library:
| Capability | What it means |
|---|---|
| Freshness policies | CacheOnly, CacheFirst, StaleWhileRevalidate, NetworkFirst, NetworkOnly — pick per call, not per architecture. |
| Request deduplication | Concurrent requests for a key share one in-flight fetch, whether they come from get or stream. |
| Reactive streams | stream(key) emits Loading / Content / Failure snapshots and keeps emitting as the key is fetched, written, or invalidated by anyone. |
| Stale-if-error | Fetch failed, cache has data? Serve the stale value and surface the error alongside it — never a blank screen. |
| Caller-proof fetches | Fetches run in the store's scope: navigating away mid-request doesn't waste the response — it still lands in the cache. |
| LRU memory cache | Bounded, thread-safe, TTL-aware. |
| Deterministic tests | Inject a WallClock and a CoroutineScope; staleness and concurrency are fully testable with virtual time. |
Pure Kotlin/JVM with a single dependency (kotlinx-coroutines): usable from any Android app
(API 21+; deliberately free of java.util methods added in API 24) and from JVM services —
persistence and Android-specific integrations layer on top as separate modules. The optional
aquifer-persistence-file module is built on java.nio.file and needs API 26+ or NIO
core-library desugaring.
Store5 is the closest neighbour in Kotlin; TanStack Query and RTK Query are the web patterns most Android teams are borrowing from. What Aquifer does differently is narrow and specific:
- Epoch fencing. Every
put/invalidatebumps a per-key epoch, and every fetch captures its epoch before it registers, so a response that was already in flight cannot resurrect a value you just deleted or edited — it is discarded at commit time instead of racing the mutation. - Absence is a state.
DataState.Emptyis an affirmative "nothing here", distinct from loading and from failure, so aCacheOnlyscreen observes a logout-style reset instead of rendering deleted data forever. - Conditional fetching that survives restarts. Validators (
ETag/Last-Modified) and a server-declaredfreshForare stored next to the value in theSourceOfTruth, so a cold start revalidates with a 304 rather than re-downloading. - A first-class fake.
fakeAquiferis public API inaquifer-test— a programmable store with assertable fetch counts — not a test-source copy each consumer rewrites.
And the honest losses: Aquifer is JVM/Android only, so a data layer shared with iOS is a real
reason to choose Store5 today; it has no mutation queue (Store5's
MutableStore/Updater/Bookkeeper, TanStack/RTK's optimistic update with rollback have no
counterpart here yet); and it is unreleased — the API is locked by binary-compatibility
validation, but nothing has been published, so none of it has been proven against a real dependent.
Migrating from Store5? Coming from Store5 maps the concepts
side by side, leads with what has no counterpart, and covers the difference that fails silently:
Store5's SourceOfTruth is a Flow, so a write its backing database observes propagates —
Aquifer's is a plain read, so an active stream is never notified of a write Aquifer did not make,
though a later cold read still picks it up.
Read-side only. put is a local write, not a pending mutation: there is no rollback, no
retry queue, and no conflict hook. A written value is authoritative until it goes stale, after
which the first successful, unfenced fetch overwrites it. "Stale" is the effective horizon, which
can arrive sooner than the configured timeToLive: a tighter per-call maxAge wins over it, and
ttlJitter shortens it (a local write has no server freshFor to consult). The always-fetch
strategies (NetworkFirst, NetworkOnly, fresh(key)) do not wait for any of that, so any of them
replaces the write immediately. That overwrite is observable only as an ordinary fetch — a new
DataState.Content and onFetchSucceeded — with nothing to say it replaced a local write, and the
write clears the entry's stored validator, so that fetch goes out unconditional — deliberately. A
validator describes the server's representation and the written body is no longer it, so a 304
would save the body while telling you only that the server still holds the version you overwrote:
nothing to reconcile against, and no safe way to treat it as confirming the local value. An
offline edit form built
on put alone will lose the user's edit once the entry goes stale and the next fetch lands.
Keep your own outbox until the planned aquifer-mutations module lands; put is
for applying a confirmed change (a server push, a response you already have) to the cache.
Not Kotlin Multiplatform today. JVM and Android only. KMP is a post-1.0 bet on the roadmap — teams sharing a data layer with iOS should look at Store5.
Declared non-goals, so the scope stays honest: image/blob caching (use Coil), cross-process shared caches, full sync engines/CRDTs, GraphQL response normalization, reflection-based serialization, and a Java-first API surface.
An Aquifer<K, V> manages one kind of data, addressed by key — Aquifer<UserId, User>,
Aquifer<Query, SearchResults>. Make it a singleton (Hilt/Koin) so every screen shares the
same cache and update bus.
A cached entry is fresh until it outlives timeToLive, then it's stale — still
servable, but due for revalidation:
| Strategy | Fresh entry | Stale entry | Missing entry |
|---|---|---|---|
CacheOnly |
cache | cache (stale) | get throws, stream emits Empty |
CacheFirst (default for get) |
cache | fetch → stale on failure | fetch |
StaleWhileRevalidate (default for stream) |
cache | cache, then revalidate | fetch |
NetworkFirst |
fetch → cache on failure | fetch → stale on failure | fetch |
NetworkOnly |
fetch | fetch | fetch |
timeToLivedefaults toDuration.INFINITE— set one. Staleness is decided by the first horizon that applies: a per-callmaxAge, else the entry's server-declaredfreshFor, else this TTL. So with nofreshness { timeToLive = … }block, any entry carrying neither override never becomes stale, and for those entries every strategy above collapses onto its fresh entry column:CacheFirstserves the first fetch forever,StaleWhileRevalidatenever revalidates,revalidateActive()(and with itrevalidateOnReconnect/revalidateOnAppForeground) refreshes only the active keys with nothing cached — a first fetch that failed while offline retries on the next trigger, unless negative caching is configured and still suppressing that key, in which case the sweep skips it until the window elapses — andisStaleis permanentlyfalse. What still reaches the network: a cache miss,NetworkFirst/NetworkOnly,fresh(key), and — under any strategy that fetches at all, so notCacheOnly— an entry whose per-callmaxAgeor server-declaredfreshForhas elapsed. That last one is also the way out for the sweep: a stream that declares amaxAgehas an override, so it is outside "those entries" above, andrevalidateActive()refreshes its key once that bar elapses. A live negative-cache suppression window still holds all of those back exceptNetworkOnly/fresh(key). Give any store whose data can change upstream atimeToLive.
Two multi-key divergences are worth knowing before you reach for them. getAll is one-shot and
awaits every fetch it triggers, so StaleWhileRevalidate there behaves like CacheFirst — it
blocks on the network rather than serving stale; use streamMany when you want
stale-while-revalidate across many keys. And maxAge is a get/stream knob only:
getAll/streamMany/prefetch/prefetchAll take freshness alone, judging staleness against
each entry's server-declared freshFor when it has one and the store's TTL otherwise.
revalidateActive() is the exception: it has no maxAge parameter of its own but it honours the
one each active stream declared, judging every key against the bars its collectors are holding it to
and refreshing if any of them considers the entry stale. So stream(key, maxAge = 30.seconds) is
swept on 30 seconds, even under the default infinite TTL, and the tightest bar wins when several
streams share a key — they all share the single resulting fetch anyway.
users.stream(id).collect { state ->
when (state) {
is DataState.Loading -> render(state.value, refreshing = true) // value = previous data, if any
is DataState.Content -> render(state.value, refreshing = false) // state.origin: MEMORY / FETCHER / LOCAL
is DataState.Failure -> renderError(state.error, fallback = state.value)
is DataState.Empty -> renderEmpty() // affirmatively nothing: CacheOnly miss or observed deletion
}
}Updates from anywhere — another screen's fetch, a local put, an invalidate — reach every
active stream of that key. Pull-to-refresh on one screen updates all of them:
suspend fun onPullToRefresh(id: UserId) {
users.fresh(id) // forces a fetch; all active streams emit Loading → Content
}
suspend fun onUserEdited(id: UserId, edited: User) {
users.put(id, edited) // local write of an already-confirmed change; observers update instantly
}put writes to the cache and nothing else — it is not an optimistic mutation, and the next
successful fetch overwrites it without ceremony (once it goes stale under the staleness-aware
strategies — which a per-call maxAge or ttlJitter can bring forward — and immediately under
NetworkFirst/NetworkOnly/fresh). See
What Aquifer is not before building an offline edit form on it.
val user = users.get(id) // fresh cache or fetch (CacheFirst)
val current = users.get(id, Freshness.NetworkFirst) // prefer network, tolerate offline
val pinned = users.get(id, Freshness.CacheOnly) // cache or CacheMissException, never fetchesOne screen can demand fresher data than the store-wide policy — or accept older — without
changing anything global. maxAge overrides the TTL for a single call or stream:
val live = users.get(id, maxAge = 30.seconds) // refetch unless really fresh
val rough = users.get(id, maxAge = 1.hours) // old data is fine here, skip the network
val any = users.get(id, maxAge = Duration.INFINITE) // serve anything cached, fetch only on miss
users.stream(id, maxAge = 1.minutes) // this stream's staleness bar, no one else'sA fetcher can also declare a per-entry lifetime the origin chose — return
FetchResult.Fresh(value, validator, freshFor = 5.minutes) from a conditionalFetcher (e.g.
mapped from an HTTP Cache-Control: max-age). The precedence, highest first, is per-call
maxAge → server freshFor → store-wide timeToLive: an explicit caller bar wins, then the
origin's declared lifetime, then the store default (only the store default is jittered). A
SourceOfTruth persists freshFor, so it survives restarts; Duration.ZERO means "revalidate
on next read", and a null (the default) means the origin had no opinion.
Warm what the user is about to open — fire-and-forget, no coroutine needed at the call site:
fun onListItemVisible(id: UserId) {
users.prefetch(id) // returns instantly; skips the fetch if already fresh, dedups with reads
}A list of 50 items shouldn't mean 50 round-trips. Give the store a batchFetcher and getAll
collapses the keys that need loading into a single call:
val users = aquifer<UserId, User> {
batchFetcher { ids -> api.fetchUsers(ids) } // POST /users?ids=… -> Map<UserId, User>
freshness { timeToLive = 5.minutes }
}
val loaded: Map<UserId, User> = users.getAll(visibleIds) // fresh keys served from cache; the rest in one callgetAll returns the resolved subset — a key the backend omits, or whose fetch fails, is
simply absent (its error still reaches AquiferEvents), so one bad key never sinks the list.
Every per-key guarantee — single-flight dedup, mutation fencing, negative caching,
persistence — holds exactly as for get; batching is purely a transport optimization, and an
individual get(id) over a batchFetcher is just a batch of one.
The reactive and warm-up twins batch the same way: streamMany(ids) combines the per-key streams
into one Flow<Map<UserId, DataState<User>>> — per-item loading/content/failure coherence, with
the misses fetched in a single call — and prefetch's plural, prefetchAll(ids), warms many keys
in one call, fire-and-forget. A retryable failure of the batch call re-runs the whole batch under
the store's retry policy.
val states: Flow<Map<UserId, DataState<User>>> = users.streamMany(visibleIds) // one batched fetch, per-item state
users.prefetchAll(nextPageIds) // warm the next page, returns instantlyIn Compose, collectAsStateMany(ids) is the lifecycle-aware binding for that map — one collector for
the whole list or grid (not a per-item collector that restarts on scroll), the multi-key twin of
collectAsState(key) (rememberStreamMany exposes the raw Flow):
val states: Map<UserId, DataState<User>> by users.collectAsStateMany(visibleIds) // one lifecycle-scoped subscriptionAlready holding the data — a websocket frame, a paginated payload? putAll(entries) is the
write-side mirror: it seeds many keys in one fenced commit (one update per key), the bulk form of
put.
Dropping data is symmetric. invalidate(key) evicts one key and invalidateAll() wipes
everything; invalidateWhere { it.startsWith("tenant:") } is the predicate middle ground — clear a
whole scope in one fenced commit, each matched key fenced exactly like invalidate, for
"drop everything for this tenant" resets.
Add a coalesceWindow and even unrelated get/stream/prefetch calls auto-batch — the
DataLoader pattern, with no change to call sites:
batchFetcher(coalesceWindow = 10.milliseconds) { ids -> api.fetchUsers(ids) }
// 50 cards each call users.collectAsState(id) in one frame -> one POST /users?ids=…Fetches landing within the window collapse into one call (dispatched when the window elapses
or once maxBatchSize keys accumulate); a transient failure re-enters the next window.
When the backend speaks ETags, conditionalBatchFetcher { validators -> … } composes 304
revalidation with batching: each key arrives mapped to its cached validator and may come back
NotModified (kept and re-aged, never re-downloaded) — the batch mirror of conditionalFetcher.
conditionalBatchFetcher { validators -> // Map<ArticleId, String?> (id -> ETag)
api.fetchArticles(validators).mapValues { (_, r) ->
if (r.status == 304) FetchResult.NotModified else FetchResult.Fresh(r.body, validator = r.etag)
}
}Add a SourceOfTruth and cached data outlives the process: cold starts render from disk
instantly, and LRU-evicted entries fall back to storage instead of the network. Timestamps are
persisted too, so time-to-live decisions stay correct across restarts.
val articles = aquifer<ArticleId, Article> {
fetcher { id -> api.fetchArticle(id) }
freshness { timeToLive = 10.minutes }
persistence(
jsonFileSourceOfTruth(
directory = context.filesDir.resolve("aquifer/articles").toPath(),
maxEntries = 500, // optional: LRU-bound the disk footprint
maxBytes = 10L * 1024 * 1024,
),
)
}aquifer-persistence-file stores one JSON file per key (kotlinx.serialization) with atomic
writes, SHA-256 file naming for arbitrary keys, and self-healing reads that treat corrupt
files as absent. The store is unbounded by default; maxEntries/maxBytes cap it with
least-recently-used eviction, and temp files orphaned by a crash are cleaned up on first
use. aquifer-persistence-sqldelight adds a queryable, enumerable SourceOfTruth on SQLDelight,
whose keys() backs a disk-wide invalidateWhere. Or implement SourceOfTruth yourself to back
Aquifer with Room or DataStore — four suspend functions (read/write/delete/deleteAll), plus optional
readAll/writeAll/deleteMany to let
getAll/streamMany/prefetchAll/revalidateActive/putAll/invalidateWhere batch
in one query or transaction (they default to the per-key loop, so overriding them is purely an
optimization). An enumerable backend may also override keys() / keysWhere(...), so a disk-wide
invalidateWhere reaches every persisted key — not just those tracked in memory — while the file
store opts out (its SHA-256 filenames are one-way).
Adding a field to your model is safe by default (unknown keys are ignored). For a breaking
change — a removed or retyped field — stamp writes with a schemaVersion and supply a
migrate so old cache files upgrade in place instead of being wiped:
jsonFileSourceOfTruth<UserId, User>( // User v2: { id, firstName, lastName }
directory = dir,
schemaVersion = 2,
migrate = { fromVersion, value -> // value is the stored JSON tree
when (fromVersion) {
0, 1 -> buildJsonObject { // v0/v1 stored a single "name"
val obj = value.jsonObject
put("id", obj.getValue("id"))
val parts = obj.getValue("name").jsonPrimitive.content.split(" ", limit = 2)
put("firstName", parts.first())
put("lastName", parts.getOrElse(1) { "" })
}
else -> null // unknown older version: drop and refetch
}
},
)Migration runs lazily on read (the entry is rewritten in the new format the next time it's
written) and only for entries below the current schemaVersion. Returning null drops the
entry — as does one stored at a higher version (an app downgrade). A version-0 store (the
default) writes no version field under the default Json (encodeDefaults off), so opting in is
byte-for-byte the old on-disk format; a caller that supplies Json { encodeDefaults = true }
emits "schemaVersion":0, as it already does for other defaulted fields.
To keep sensitive values off disk as plaintext, pass a cipher — a two-method ValueCipher
(encrypt/decrypt) applied to each entry's serialized bytes. The seam depends on nothing
beyond the JDK, so production crypto plugs in with a thin adapter:
class TinkValueCipher(private val aead: Aead) : ValueCipher { // Tink + Android Keystore
override fun encrypt(plaintext: ByteArray, aad: ByteArray) = aead.encrypt(plaintext, aad)
override fun decrypt(ciphertext: ByteArray, aad: ByteArray) = aead.decrypt(ciphertext, aad)
}
jsonFileSourceOfTruth<UserId, User>(directory = dir, cipher = TinkValueCipher(aead))The on-disk bytes — and the maxBytes budget — are the ciphertext, and a decrypt that
throws GeneralSecurityException (wrong key, tampered file) heals the slot like any other
corrupt entry. The entry's key is passed as authenticated associated data, so a blob copied to
a different key's file on disk is rejected, not served as that key's value. Encryption composes
with bounding, conditional fetching, and schema migration.
Fetches are not retried by default. Opt in per store:
retry {
maxAttempts = 3 // total, including the first attempt
initialDelay = 250.milliseconds // then ×2 per attempt, capped at maxDelay
retryOn = { it is IOException } // never retries cancellation
}The expiry-side mirror is freshness { ttlJitter = 0.1 }: entries fetched together get
deterministically spread expiries (never later than timeToLive), so a list screen's 50
items don't all revalidate in the same frame.
Retries happen inside the shared single-flight fetch: observers see one Loading and one
terminal state per cycle, and jitter only ever shortens delays so maxDelay is a hard cap.
When the backend supports HTTP revalidation, a stale entry doesn't need a re-download to
become fresh again. A conditional fetcher receives the cached entry's validator and may
answer NotModified — the store keeps the value and restarts its TTL:
val articles = aquifer<ArticleId, Article> {
conditionalFetcher(
okHttpConditionalFetcher( // aquifer-okhttp wires the headers
callFactory = client,
request = { id -> Request.Builder().url("$BASE/articles/$id").build() },
parse = { _, body -> json.decodeFromString<Article>(body.string()) },
)
)
freshness { timeToLive = 10.minutes }
persistence(jsonFileSourceOfTruth(dir)) // validators survive restarts too
}Responses' ETag/Last-Modified headers are captured automatically, replayed as
If-None-Match/If-Modified-Since, and a 304 re-ages the entry without the payload ever
crossing the network. A non-success status throws HttpException, which carries the response
code so a retry or negative-cache policy can branch on it (e.g. retry only 5xx). For a
backend without revalidation, okHttpFetcher feeds the plain fetcher the same way (no
validator, same HttpException), and Call.await() is public if you'd rather hand-roll one.
No OkHttp at all? Implement the two-line when yourself — the
conditionalFetcher { key, validator -> FetchResult } contract is transport-agnostic.
Pass okHttpConditionalFetcher(..., respectCacheControl = true) to let the origin drive
freshness: a 2xx response's Cache-Control: max-age (minus any Age) becomes the entry's
freshFor, no-store/no-cache/max-age=0 mark
it immediately stale, and Expires is the fallback. It's off by default, and the precedence is
always per-call maxAge → server freshFor → store timeToLive.
A failing endpoint shouldn't be hammered by every screen that asks. Opt in per store:
negativeCache {
timeToLive = 30.seconds // remember failures this long
backoffMultiplier = 2.0 // consecutive failures stretch the window
maxTimeToLive = 5.minutes // hard cap
maxEntries = 512 // bound the failure memory (LRU); default 512
}During the window, reads serve cached data when it exists (stale-if-error, without
re-asking the network) and otherwise fail fast with the remembered error; new stream
subscribers see it instantly instead of triggering another doomed request. fresh() /
NetworkOnly still go to the network — an explicit demand is honoured — and any success,
put, or invalidate clears the memory. The failure memory is LRU-bounded by maxEntries,
so an unbounded key space of one-time failures can't grow it forever; evicting a record only
re-permits a fetch of that key (records carry no value, so it never resurrects data).
revalidateActive() refreshes exactly the keys someone is currently looking at — active
streams — and only if their entries are stale or missing, staleness being judged against each
stream's own maxAge where it declared one. With a batch fetcher configured the whole sweep goes
out as one call rather than one per key, through the same transport getAll uses.
aquifer-android ships the two triggers every app wants:
users.revalidateOnReconnect(context) // internet restored (ConnectivityManager-backed)
users.revalidateOnAppForeground() // app returned to foreground (ProcessLifecycleOwner)Both fire only on genuine offline→online / background→foreground transitions: an
already-online device or already-visible app at subscription never emits, subscribing while
offline or backgrounded emits on the next recovery, and Wi-Fi↔cellular handovers are ignored.
The required ACCESS_NETWORK_STATE permission is merged in from the library manifest. For anything custom, revalidateOn(trigger) accepts
any Flow — a push message, a settings change, a timer.
Pull-to-refresh is the case where staleness is beside the point: the user pulling the list down
is telling you to disregard the freshness bars the app chose for itself. Pass force:
suspend fun onPullToRefresh() = users.revalidateActive(force = true)That refreshes every active key regardless of age, still sharing one fetch per key. It overrides
staleness, not negative caching — a key inside a suppression window is still skipped, because
that window remembers a failing endpoint rather than a fresh value, and a sweep touches everything
on screen at once. fresh(key) remains the per-key override that ignores the failure memory too.
events(object : AquiferEvents<UserId> {
override fun onFetchFailed(key: UserId, error: Throwable, attempts: Int) =
Timber.w(error, "fetch failed after $attempts attempts")
override fun onPersistenceWriteFailed(key: UserId, error: Throwable) =
analytics.count("cache_write_failed")
})Hooks cover fetch start/success/failure, every retry, and best-effort persistence write failures. Listeners that throw never disturb the engine.
For a point-in-time peek, snapshot() returns the keys currently resident in memory —
non-suspending, never any I/O, safe to call from anywhere (a debug overlay, eviction tuning):
val resident = users.snapshot() // Set<UserId> in memory right now
debugOverlay.show("cached: ${resident.size}/$maxEntries", resident)It lists memory only — persisted-but-evicted keys aren't included — and returns a stable copy, not a live view.
stats() is the counters companion — hit/miss totals, LRU evictions, and the current in-flight
fetch-registry size, the aggregate numbers the event hooks above can't give you, for hit-rate
dashboards and cache tuning (also non-suspending and I/O-free):
val s = users.stats()
debugOverlay.show("hit rate ${(s.hitRate * 100).toInt()}% in-flight ${s.inFlight} evicted ${s.evictions}")A hit is a read satisfied from cache under its requested Freshness without awaiting a fetch (a
fresh entry for CacheFirst, a present one for CacheOnly/StaleWhileRevalidate); a miss is any
other read — the policy needed a fetch, or CacheOnly found nothing (NetworkFirst/NetworkOnly
always miss). Background revalidation and prefetch warmups aren't counted.
The memory tier is a cache, not the source of truth, so it's safe to shed under pressure.
evictMemory() drops every resident entry; trimToSize(maxEntries) keeps the maxEntries
most-recently-used and drops the least-recently-used rest. Both are non-suspending, silent, and
memory-only — persistence is untouched, so each dropped key rehydrates from disk (no network fetch,
unchanged staleness) on its next read.
override fun onTrimMemory(level: Int) = when {
level >= TRIM_MEMORY_COMPLETE -> users.evictMemory()
level >= TRIM_MEMORY_MODERATE -> users.trimToSize(64)
else -> Unit
}
override fun onLowMemory() = users.evictMemory()Like snapshot()/stats() they're safe to call from anywhere, including a closed store, and they
don't disturb active streams, fence in-flight fetches, or count toward stats().evictions. On a
store with no persistence the memory tier is the only tier, so dropped data is gone and the next
read re-fetches — reach for these on a persistence-backed store.
Aquifer takes time and concurrency as injectable dependencies, so tests are deterministic:
@Test
fun `stale profile is served then revalidated`() = runTest {
val clock = FakeClock()
val users = aquifer<String, User> {
scope(backgroundScope) // background work runs on the test scheduler
clock(clock) // staleness under your control
fetcher { fakeApi.user(it) }
freshness { timeToLive = 5.minutes }
}
users.put("u1", cachedUser)
clock.advanceBy(10.minutes)
users.stream("u1").test {
assertEquals(DataState.Content(cachedUser, Origin.MEMORY, isStale = true), awaitItem())
assertEquals(DataState.Loading(cachedUser), awaitItem())
assertEquals(DataState.Content(freshUser, Origin.FETCHER, isStale = false), awaitItem())
}
}The FakeClock and settle() helpers above ship in aquifer-test (add it as a
testImplementation dependency). That module also provides fakeAquifer — a programmable,
in-memory store for unit-testing a repository that depends on an Aquifer, without standing up the
real engine. Script per-key values, failures, and delays, then assert how often each key was
fetched:
val users = fakeAquifer<String, User>(backgroundScope) {
seed("ada" to User("ada")) // already cached, no fetch
returns("grace", User("grace")) // fetched on demand
failsWith("bad", IOException()) // fetch fails
}
val repo = UserRepository(users)
assertEquals(User("grace"), repo.load("grace"))
assertEquals(1, users.fetchCount("grace")) // assert it fetched, exactly oncefakeAquifer trades a few of the real engine's behaviors for determinism: it has no
time-to-live (a cached value never goes stale on its own, so maxAge is validated but inert),
no single-flight deduplication (two concurrent loads of the same missing key each fetch and
each count), and reports stats() as CacheStats.EMPTY — assert on fetchCount/fetchedKeys
instead. Its evictMemory()/trimToSize() model a single cache tier, so (unlike the real store's
silent shed) they reach active CacheOnly collectors and don't keep LRU recency. For TTL,
staleness, single-flight, or shed-around-stream behavior, test against the real store paired with
FakeClock.
- Single-flight fetches. A per-key registry of in-flight
Deferreds collapses concurrent fetches; the registry cleans up on completion, so memory use is bounded by concurrency, not key cardinality. - No lost updates. A stream subscribes to the update bus before reading its cache
snapshot (via
SharedFlow.onSubscription), so an update can't slip between snapshot and subscription. - Fetches outlive callers. Fetches run in the Aquifer's scope, not the caller's: a
response that arrives after the requesting screen closed still warms the cache for the next
screen. Cancelling the store's scope (or
close()) cancels everything. - Errors are data. Streams never terminate on fetch failure; failures are emitted as
DataState.Failurecarrying the last known value, and broadcast to all observers of the key. - So is absence. A
CacheOnlystream of a missing or invalidated key emitsDataState.Empty— an affirmative "nothing here", distinct from loading and from failure — so cache-only screens observe logout-style resets instead of rendering deleted data forever. - Slow collectors are isolated. Every stream drains the store's update bus through an unbounded per-collector buffer on the store's dispatcher, so one stalled screen can never block fetch completion, writes, or other streams.
- Fencing records are per-key and long-lived. Each fenced key keeps a small epoch record (a
key and a
Long, no value) untilinvalidateAll()clears the set, so a very wide key space on a long-lived process — a search or autocomplete store — grows a modest per-key map. Bounding it soundly needs an atomic capture across the whole read path, not just the fetch site; until theninvalidateAll()is the mitigation. Tracked as #13.
| Thrown | When |
|---|---|
CacheMissException |
get(key, Freshness.CacheOnly) with nothing cached. CacheOnly streams emit DataState.Empty instead. |
BatchKeyMissingException |
a batchFetcher returned a map omitting a requested key. It fails that key alone — the rest of the batch is unaffected. |
HttpException (aquifer-okhttp) |
an unsuccessful response — neither 2xx nor 304 for okHttpConditionalFetcher, any non-2xx for the plain okHttpFetcher. Carries code/url and extends IOException, so it flows through retryOn and negativeCache like any transport failure. |
AquiferException |
the base type of CacheMissException and BatchKeyMissingException (not HttpException), and thrown directly to callers awaiting a fetch when the store closes underneath them (never a bare cancellation of the caller's own coroutine). |
Your fetcher's own exceptions are never wrapped: they arrive in DataState.Failure.error
unchanged, which is what makes retryOn = { it is IOException } work and lets a DataState.Failure
consumer discriminate on the concrete type. Whether get throws one depends on stale-if-error:
CacheFirst and NetworkFirst return the cached value when a fetch fails and one is available,
and only rethrow when there is nothing to fall back on; NetworkOnly/fresh always rethrow.
close() cancels in-flight fetches and stops update delivery; cancelling the scope passed to
scope(...) does the same, and closing twice is a no-op. Afterwards most members throw
IllegalStateException, while the non-suspending memory-only operations — snapshot(),
stats(), evictMemory(), trimToSize(n) — stay callable. Streams already collecting simply
stop receiving: they neither complete nor throw, so end them by cancelling the scope that
collects them (viewModelScope, backgroundScope) rather than waiting on the flow.
Fetches and fire-and-forget work (prefetch, background revalidation) run under the store's own
SupervisorJob, on Dispatchers.Default unless you inject a scope — so fetchers execute on the
CPU-sized pool. Your SourceOfTruth is called from both sides: a direct read or write
(get's hydration, put, invalidate) runs on the caller's coroutine, while a conditional
fetch's prior read and every post-fetch write-through run on the store's scope. So a blocking
persistence implementation must dispatch itself — it cannot rely on Aquifer to move it, and on the
direct paths it blocks the caller. A suspending, non-blocking fetcher needs nothing;
a blocking one (Call.execute(), JDBC, file reads) must be wrapped, or it will starve the
default dispatcher under load:
fetcher { id -> withContext(Dispatchers.IO) { blockingApi.load(id) } } // or scope(ioScope)snapshot(), stats(), evictMemory(), and trimToSize(n) are non-suspending and I/O-free,
callable from any thread including the main one.
./gradlew :sample:run # eleven annotated scenarios, from cold start to the cache counters
./gradlew dokkaGenerate # aggregated API docs in build/dokka/
./gradlew build # tests + binary-compatibility check (api/*.api dumps)The tour runs the core loop first — cold start, stale-while-revalidate, a confirmed change via
put, "process death" served from disk, reconnect-with-retry — then the features an app reaches
for: single-flight de-duplication, prefetch, batched getAll, conditional (304) fetching,
negative caching, and the stats/snapshot counters. Each prints what the fake backend actually
saw, so "five concurrent reads, one API call" is a counter rather than a claim.
ROADMAP.md is the single ordering of record — what has shipped, what is next
through 1.0 and beyond (KMP, offline mutations, a Paging bridge), and the declared non-goals.
Everything before the first tag sits in its Now milestone; the headline is v0.1.0 on Maven
Central: the engineering is done, leaving the owner-side signing secrets and the version bump off
-SNAPSHOT. See the roadmap for the rest and for
their order — this section deliberately does not restate it.
| Module | Description |
|---|---|
aquifer-core |
The store: public API + engine. Pure Kotlin/JVM, depends only on kotlinx-coroutines-core. |
aquifer-compose |
Jetpack Compose integration: collectAsState(key) / collectAsStateMany(keys), rememberStream / rememberStreamMany, previewAquifer (molecule-tested). |
aquifer-android |
Android library: revalidateOnReconnect / revalidateOnAppForeground triggers (Robolectric-tested). |
aquifer-persistence-file |
JSON-files SourceOfTruth backed by kotlinx.serialization: atomic writes, self-healing reads. |
aquifer-persistence-sqldelight |
SQLDelight SourceOfTruth: queryable, batched (IN-clause + transactions), and enumerable (disk-wide invalidateWhere). |
aquifer-okhttp |
OkHttp conditional fetching: automatic ETag/Last-Modified revalidation, 304 → NotModified. |
aquifer-test |
Test doubles for consumers (testImplementation): fakeAquifer with assertable fetch counts, FakeClock, settle(). |
sample |
Runnable CLI tour: the core loop (cold start, stale-while-revalidate, put, process death, reconnect) then single-flight dedup, prefetch, batching, 304s, negative caching, and the counters (./gradlew :sample:run). |
Copyright 2026 Quasar Apps
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.