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.

On this page

    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.

    Step 1Your service receives a request

    It asks the cache for one key, or for an index value.

    →check
    Step 2Near cache (L1, optional)

    Caffeine, inside this pod. Microseconds.

    →on miss
    Step 3Redis / Valkey (L2)

    Shared by every pod of every application. About a millisecond.

    →on miss
    Step 4Load from the source

    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.

    StaticMaster data — small, changes rarelyThe whole table is loaded as one versioned snapshot. Readers switch to a new version only after a later reload succeeds, so they never observe a partially loaded table. No TTL. Refreshed by a schedule, an operator call or a refresh event. Examples: countries, event configurations, booking-centre mappings.
    TransactionalData that changes — loaded row by rowA bounded working set is warmed, an unexpected miss is loaded by key, every entry carries a TTL, and writes evict or refresh the key after commit. Examples: user alerts, transactions, external lookups (IDM, PAS, LDAP).
    statictransactional
    declared with@CacheSnapshot(region = …) or RegionBinding.staticRegion(…)@CacheSnapshot(region = …, type = TRANSACTIONAL, ttl = …) or RegionBinding.transactional(…)
    loadedwhole table at startup, by the leader; stored as v1, v2, …per key on first read (read-through) or cache-aside via getOrLoad
    expiresnever — replaced by the next versionafter l2-ttl (plus a shorter l1-ttl if set)
    kept fresh byrefresh-cron, startup: reload-if-stale, refresh endpoint, refresh eventsHibernate write binding, CacheFreshness, refresh events, put/remove, TTL
    put()ignored with a warning — a static region is replaced whole, never patchedwrites after the surrounding transaction commits
    trusted whena 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.

    OwnedYour application's dataRows in your database — you are the source of truth and you see every write. Caching it is the normal case: the Hibernate write binding or CacheFreshness evicts on every commit, so the cache follows the data automatically. Examples: event configurations, user alerts.
    External · optionalAnother application's dataData you only read over an API — PDI, PAS, IDM, LDAP. You never see its writes, so freshness rests on a TTL (plus refresh events when the owner publishes them). Caching it is optional: an opt-in region behind a flag that spares you a network hop and shields you from the other system's outages — this is how indc-core caches its PDI and PAS lookups.
    owned dataexternal application's data
    source of truthyour databasethe other application, reached over its API
    region kindstatic (master data) or transactionalalmost always transactional, cache-aside via getOrLoad
    kept fresh bywrite bindings and refresh events — you see every writeTTL first; refresh events only if the owner publishes them
    is caching optional?optional like every region, though typically the primary motivation for adopting the libraryexplicitly optional — declare it behind a flag and be ready to run without it
    when the cache is offreads go to your database, exactly as beforereads 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 forwriters that bypass JPA must evict or publish a refresh eventchoose the TTL from how stale the owner allows you to be; never cache what you are not entitled to store
    Note

    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

    The rules a snapshot follows

    rulewhy
    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 handThe 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 needSmaller Redis values, faster serialisation, no client data cached by accident.
    Never the only copy of a valueEvery 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 contractAdding 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.
    Note

    "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.)

    Access & stitchAbstractDacCache 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.
    Data plane — the facadeRegionBinding 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.
    Cache plane — the engineDacCacheManager 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.

    flagwhat it turns on
    dac.cache.enabledThe whole engine: regions, leader, invalidation bus. Needs spring.data.redis.*. Off = every facade serves from its source (or its local fallback).
    dac.domains.<name>.enabledOne domain. Default comes from the binding (.domain("idm-user", true)), overridable per environment.
    application flags, e.g. app.cache.user-alerts.read-enabledAn 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.

    INTERNAL NETWORK · BARENET KUBERNETES · YOUR SERVICE Internal callersservices · UIs · APIs Load balancerK8s Service Pod 1 · Spring Boot + DAC CacheL1 · Caffeine · in this pod onlyLEADER Pod 2 · Spring Boot + DAC CacheL1 · Caffeine · in this pod only Pod 3 · Spring Boot + DAC CacheL1 · Caffeine · in this pod only Redis / Valkey · Sentinel (L2)▸ shared values · one copy for every pod▸ invalidation pub/sub · clears every L1▸ refresh-event stream · other systems write to▸ leader lease · versioned snapshotskeys: {app}:cache · shared: dac:<domain> Database · external APIsthe source of truthreached only by the loader on a miss request any pod L2 get · set evict L1 · pub/sub · all pods loader · only on miss bulk load · leader only
    Pick a componentClick any box to see what it does, or press Trace a read to follow one request through the cache.

    Failures and instance lifecycle

    what happenswhat consumers seehow it recovers
    A pod failsNo 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 startupThe 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 runningL1 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-eventThe 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 failsThe 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 eventThe 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 valueAbstractDacCache hides it (Optional.empty() / filtered from lists) or throws with DenyMode.FAIL.Not an error condition — the cached value is shared and unchanged.
    Important

    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.