Concepts
The mental model behind every page that follows: how a read travels, the two kinds of region, whose data you are caching, what a snapshot is, how the data plane and the cache plane divide the work, and what the library does when something fails.
How a cache read works
Every cached read follows the same path. A hit returns immediately; a miss moves to the next step. Only the final miss reaches your database or external source.
It asks the cache for one key, or for an index value.
Caffeine, inside this pod. Microseconds.
Shared by every pod of every application. About a millisecond.
Read once, cache the result, return it.
Any pod can serve any request. The near cache (L1) is only a temporary local copy, so there is no session affinity. A pod that fails loses its own L1 and nothing else; the replacement fills L1 from L2 or the source as requests arrive.
The near cache is optional, per region. It is on by default because it is what makes hot
reads free, but a region can run L2-only with .l1MaxSize(0) (or
dac.cache.regions.<region>.l1-max-size: 0) — the right choice when a value must
never be a pub/sub message behind the shared copy, or when pods are memory-tight and the
millisecond to Valkey is fine.
Regions — the one design decision
A region is one named cache of one value type under one key type. Choosing the region kind is the only design decision you make per table; everything else follows from it.
| static | transactional | |
|---|---|---|
| declared with | @CacheSnapshot(region = …) or RegionBinding.staticRegion(…) | @CacheSnapshot(region = …, type = TRANSACTIONAL, ttl = …) or RegionBinding.transactional(…) |
| loaded | whole table at startup, by the leader; stored as v1, v2, … | per key on first read (read-through) or cache-aside via getOrLoad |
| expires | never — replaced by the next version | after l2-ttl (plus a shorter l1-ttl if set) |
| kept fresh by | refresh-cron, startup: reload-if-stale, refresh endpoint, refresh events | Hibernate write binding, CacheFreshness, refresh events, put/remove, TTL |
put() | ignored with a warning — a static region is replaced whole, never patched | writes after the surrounding transaction commits |
| trusted when | a version is live (CacheRegion.isLoaded()) | always, once the engine is up |
Whose data is it — owned or external
Orthogonal to the region kind is a question the design keeps explicit: who owns the data you are caching? The answer decides how the cache is kept fresh and whether it should exist at all.
CacheFreshness evicts on every commit, so the cache follows the data automatically. Examples: event configurations, user alerts.| owned data | external application's data | |
|---|---|---|
| source of truth | your database | the other application, reached over its API |
| region kind | static (master data) or transactional | almost always transactional, cache-aside via getOrLoad |
| kept fresh by | write bindings and refresh events — you see every write | TTL first; refresh events only if the owner publishes them |
| is caching optional? | optional like every region, though typically the primary motivation for adopting the library | explicitly optional — declare it behind a flag and be ready to run without it |
| when the cache is off | reads go to your database, exactly as before | reads call the external API, exactly as before — plus .localFallback(n) so an L2 outage does not translate into excessive direct load on the other system |
| watch out for | writers that bypass JPA must evict or publish a refresh event | choose the TTL from how stale the owner allows you to be; never cache what you are not entitled to store |
Shared domains are the third variant: data owned by another application but cached cooperatively under dac:<domain> (like CAST), where the owner and the readers agree on the snapshot contract. Treat it as external data with a contract — still optional, still flag-gated per application.
What a snapshot is — and what it is not
Every value in the cache is a snapshot: a small, read-only copy of one row, stored as JSON in Redis and as an object in each pod's memory. Both region kinds cache snapshots; the difference is only how they are loaded.
Why not the JPA entity
- An entity is tied to a database session. It carries proxies and lazy relations that only work while that session is open. Serialised into Redis and read back in another pod it fails, or drags along data nobody asked for.
- An entity can be modified. A cached value is shared by every thread in the pod and by every pod; if one caller could change it, all readers would see the change. A snapshot is a Java record: immutable by construction.
- An entity has the shape of the table. A snapshot has the shape readers need — flatter, sometimes with a value pulled from a related table, never with fields nobody reads.
The rules a snapshot follows
| rule | why |
|---|---|
| A Java record with plain fields (numbers, strings, dates, enums, nested records, lists of those) | Serialises to JSON and back reliably; cannot be modified; equal by content. |
Built by one static from(entity) — generated by @CacheSnapshot or written by hand | The mapping lives in one place and runs inside the loader's read-only transaction, so it may safely read lazy relations. |
| Contains only what readers need | Smaller Redis values, faster serialisation, no client data cached by accident. |
| Never the only copy of a value | Every snapshot can be rebuilt from its source at any time — that is what makes it a cache and not a second database. |
| Its fields are a contract | Adding a field is safe. Renaming, removing or retyping one is a breaking change: rename the region or reload it in the same deployment, and never on a region another application reads. |
"Snapshot" is not the GoF Memento pattern. A memento is restored back into the object it came from. Our snapshots flow one way — database → loader → cache → reader — and are never written back. The right name is a read model or projection: an immutable value object produced by a mapping method and stored under a key.
The three layers — data plane and cache plane
Applications rarely touch the cache plane directly. They subclass a data-plane facade, and the facade asks the cache plane for a region. Each layer only adds packaging — never new cache semantics. (In code and in older pages the data plane is "the facade" and the cache plane is "the engine" — same things, same classes.)
AbstractDacCache decides, on the way out, whether the caller may see a value (allow / deny / replace with a trimmed copy). @Stitch fills annotated method parameters; @DacAuthorize filters a method's result. The cached value itself stays principal-neutral so it can be shared.RegionBinding is the pure description of a region. AbstractCache is the typed entry point: get, byIndex, put, refresh… with the cache-plane-off fallback, the domain flag, the transaction guards and the log context built in. CacheSupport is the data plane's only link to Spring and the cache plane.DacCacheManager owns the regions. A CacheRegion is a TieredCache (optional near cache L1 + L2 Redis) plus indexes, versioning, read/write policies and statistics. Shared machinery: invalidation pub/sub, leader lease, refresh-event stream, write-behind queue, cluster broadcast, Micrometer gauges.Domains and flags
A domain is a named group of regions that switches on and off together, under the property
dac.domains.<name>.enabled. A region that belongs to a domain lives under the Redis
root dac:<domain> instead of the application's own root, which is what lets several
applications share it. When a domain is off, every facade in it falls back to its source and every
@Stitch parameter in it resolves to null — cache and transport flip together,
so a rollout is one flag and reversible.
| flag | what it turns on |
|---|---|
dac.cache.enabled | The whole engine: regions, leader, invalidation bus. Needs spring.data.redis.*. Off = every facade serves from its source (or its local fallback). |
dac.domains.<name>.enabled | One domain. Default comes from the binding (.domain("idm-user", true)), overridable per environment. |
application flags, e.g. app.cache.user-alerts.read-enabled | An application's own gate for a region with an external precondition (here: alert-be-app must evict on every write first). |
How it fits into an application
DAC Cache is a library inside your service, not another application to deploy. In the common case the service, its database and Redis / Valkey all run in the internal network. When part of the application is internet-facing, the DMZ pods run the same library and need exactly one thing: a TCP route to the Redis / Valkey in the internal network. They never connect to the database directly.
Switch between the two deployments, then trace a read, a write or a Redis outage to watch the path a request takes. Click any box for what it is responsible for.
- One Redis / Valkey does four jobs: it stores the shared values, carries the eviction messages that clear every pod's near cache, holds the refresh-event stream other systems write to, and carries the cluster-broadcast channel behind
/actuator/dachealth. - Both zones share one L2. A value loaded by a core service is a hit for a DMZ service, and an eviction in either zone reaches both.
- Writers stay responsible. A batch job that writes the database directly must evict the key or publish a refresh event after its commit; otherwise readers keep the old value until the TTL expires.
- Keep client data out of the cache. Keys and values travel to a store readable in both zones — cache technical identifiers only.
Failures and instance lifecycle
| what happens | what consumers see | how it recovers |
|---|---|---|
| A pod fails | No session state is lost. Requests move to another pod; its L1, the shared L2 or the source answers. | The replacement fills its own L1 as requests arrive. |
| Redis is unavailable at startup | The application still starts. Regions are off and every facade serves from its source (or its in-process local fallback). | Restart after Redis is reachable. Startup does not retry silently in the background. |
| Redis fails while running | L1 hits remain immediate. An L1 miss waits up to the Redis timeout, then the loader reads the source; the result is kept in L1 even if L2 cannot be updated. | Reads and writes resume when the connection recovers. Watch the warnings and source-load rate. |
| A static region is not loaded yet (new pod, non-leader) | The facade treats the region as absent and runs its source fallback; it never answers from an empty version. | The leader publishes the version; every pod attaches to it. |
| A refresh consumer fails mid-event | The previous value is served while the event is pending. | Redis Streams keeps the unacknowledged event; another pod claims it after claim-min-idle. |
| A refresh loader fails | The previous value stays available; readers never wait for a refresh. | The event stays pending and is retried; TTL, scheduled reload and operator refresh are further repair paths. |
| The producer never publishes the event | The value can be stale. | TTL bounds the stale period for transactional data; static regions need a scheduled or operator reload. Use a transactional outbox when delivery must be guaranteed with the commit. |
| A caller is not entitled to a value | AbstractDacCache hides it (Optional.empty() / filtered from lists) or throws with DenyMode.FAIL. | Not an error condition — the cached value is shared and unchanged. |
Secondary-index lookups cannot rebuild an index from one primary-key load. When Redis is unavailable they return a miss so the facade's loadFromSourceByIndex or the .or(repository…) branch can run the indexed source query. Keep that fallback in the facade.