Start here

DAC Cache integration guide

Configure static and transactional cache regions in a Spring Boot service. Reads check local memory first, then shared Redis or Valkey, and use the configured source when the value is missing.

What you addOne starter dependency and one cache region for each kind of data.
What you keepYour database remains the source of truth. The cache is always a replaceable copy.
What you gainFewer database calls, faster reads, and the same cache shared across service instances.

Set it up in four steps

You need a Spring Boot service, a database repository, and a Redis or Valkey connection. For a local run, this is enough: docker run -p 6379:6379 valkey/valkey:8.0.

Step 1 — Add one dependency

<dependency>
    <groupId>com.juliusbaer.dac</groupId>
    <artifactId>cache-starter</artifactId>
</dependency>
it containswhat for
cache-coredoes the actual caching: memory, Redis/Valkey, expiry, loading and metrics
cache-binding-springkeeps cached rows fresh when your service inserts, updates or deletes data
cache-masterdata-starterloads small reference tables and gives you refresh endpoints

And one build-time dependency, so the cache records are generated from your entities (Step 3). It is provided and goes on the compiler's processor path, the same way Lombok does:

<dependency>
    <groupId>com.juliusbaer.dac</groupId>
    <artifactId>cache-snapshot-processor</artifactId>
    <scope>provided</scope>
</dependency>

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></path>
            <path><groupId>com.juliusbaer.dac</groupId><artifactId>cache-snapshot-processor</artifactId></path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
✓ Check: restart your application. It starts exactly as before — if Redis is not configured, the cache stays off and your existing database reads still work.

Step 2 — Point at Redis

spring:
  data:
    redis:
      host: localhost
      connect-timeout: 250ms # example: fit these limits to your request SLA
      timeout: 500ms
✓ Check: restart and watch the log. You will see Cache manager started successfully with 0 region(s) — the engine is running, nothing is cached yet. If Redis is unavailable at startup, the application still starts and reads from its normal source. The timeouts also limit how long an L1 miss waits when Redis fails later.

Step 3 — Cache your first table

Pick a small reference table — one that changes rarely. Put one annotation on its JPA entity; that is the entire cache configuration. The build generates ProductCategorySnapshot, the record that is stored in the cache:

@Entity
@CacheSnapshot(region = "productCategories",
        indexes = @SnapshotIndex(name = "byName", property = "name"))
public class ProductCategory {
    @Id private Long id;
    private String name;
    private String description;
    // getters
}

Read it anywhere — no repository call:

masterData.lookup(ProductCategorySnapshot.class).byIndex("byName", "Bonds");
✓ Check: restart and watch the log: Region 'productCategories' loaded snapshot v... (3 entries) — the whole table went into the cache at startup. Call your read twice: the database is not queried at all; both answers come from the cache.

The complete example is in the static data guide. The record can also be written by hand — see Generating snapshots.

Step 4 — Cache data that changes (with a TTL)

Do not copy a large transaction table into the cache without a limit. Define how one missing row is loaded and how long it stays cached. You can then load a bounded default window, or populate the cache when a relevant application event occurs.

# application.yaml — change the default through TRANSACTION_CACHE_TTL
dac:
  cache:
    regions:
      transactions:
        l2-ttl: ${TRANSACTION_CACHE_TTL:30s}
@Bean
CacheRegionConfig<String, TransactionSnapshot> transactions(TransactionLedger ledger) {
    return CacheRegionConfig.transactional("transactions", String.class, TransactionSnapshot.class)
            .loader(ledger)                     // cache miss -> one database read
            .build();                           // TTL comes from application.yaml above
}

@Bean
CacheWriteBinding<Transaction> transactionWrites() {
    // Hibernate observes normal JPA insert, update and delete operations.
    return CacheWriteBinding.evict(
            "transactions", Transaction.class, Transaction::getId);
}

// Recommended: warm the bounded working set before accepting traffic.
region.warm(transactionRepository.findCreatedSince(cutoff));

The starter applies dac.cache.regions.transactions.l2-ttl to this region at startup. The bean defines the loader and region behaviour, while each environment controls the TTL without rebuilding the application.

When the application already uses Spring Data JPA with Hibernate, the write binding activates automatically. Repository and entity-manager writes need no cache call in the service: the key is evicted only after a successful commit, while a rollback leaves the cached value unchanged.

✓ Check: read one row twice. The log shows one database load for the first read (Cache miss in region 'transactions' reached the backing loader); the second read is answered from memory in well under a millisecond. Wait 30 seconds and read again — the entry expired by itself and one fresh database load happens.

Next references

Where the code lives

The library is developed in the client-notification-handler repository under dac/:

modulewhat it is
dac/libs/cache-corethe engine
dac/libs/cache-masterdata-starterstatic tables from an annotation + the admin endpoints
dac/libs/cache-binding-springwriter-side eviction and optional feature switches
dac/svcs/dac-initializerthis initializer

The library will move into a common GitLab repository with its own release cycle, so every team can consume it without depending on this repo. The module names and Maven coordinates (com.juliusbaer.dac) stay the same — only the repo links above will change when that happens.

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.

Static dataLoad a complete, small snapshot at startup. Refresh it when the source changes.
Transactional dataLoad a bounded useful set, load individual misses, and refresh changed rows after database writes or business events.

Compatibility

The current release has one tested baseline. Use these versions unless the library is updated and successfully tested with another combination.

partsupported baselinewhat this means
Java / JDK25Build and run the library with JDK 25.
Spring Boot4.0.6This is the version used to build and test every DAC module.
Spring Framework and Spring DataManaged by Spring Boot 4.0.6Keep Spring Boot dependency management instead of overriding these versions separately.
Hibernate write bindingHibernate ORM managed by Spring Boot 4.0.6Normal JPA inserts, updates and deletes are observed automatically. Bulk JPQL or native SQL still needs an explicit CacheFreshness call.
Redis / ValkeyRedis-compatible server; examples use Valkey 8.0The library connects through Spring Data Redis and Lettuce.
Not verified yet: Spring Boot 3.x and JDK 17 or 21. Treat those combinations as unsupported until they have a successful build and test run.

How it fits into an application

DAC Cache is a library inside your service, not another application to deploy. Each service instance keeps a small local cache (L1). All instances share Redis or Valkey (L2). If neither has the value, your loader reads it from the database or another source.

Two deployments, same library. Pick the one that matches where your service runs.

The common case: the service, its database and Valkey all run in the internal network. Nothing about the cache is exposed outside.

INTERNAL NETWORK · BARENETInternal callersservices · UIs · APIsLoad balancerK8s ServiceREAD PATH, IN ORDER1. L1 in this podmicroseconds2. L2 in Valkeyabout a millisecond3. loader → sourcemilliseconds and upevery hit is a call thatdid not reach the sourceKUBERNETES · YOUR SERVICEPod 1Spring Boot + DAC Cache L1Pod 2Spring Boot + DAC Cache L1Pod 3Spring Boot + DAC Cache L1Any pod serves any request — L1 is only a local copy,so no sticky sessions or routing affinity are needed.Valkey · L2 (shared)primary + replicas, Sentinelone keyspace prefix per applicationONE VALKEY, THREE JOBS1. shared store — a miss here is a hit there2. eviction messages — clear every pod's L13. refresh stream — others send {region, key}Databasessource of truthHost & third-partycore banking · market dataBatch jobswrite the DB directlyany podevery pod ↔ L2miss → loaderor a host APIafter commit:evict or send an event

No session or routing affinity is required. L1 is only a temporary local copy. Any request can go to any instance because every instance can check the shared L2 and the source. If an instance dies, only its L1 copy is lost. A replacement instance rebuilds L1 from L2 or the source as requests arrive.

Use the following as the default. It gives teams one clear path while keeping the database or external system as the source of truth.

data typerequired loading patternrequired freshness rule
StaticLoad the complete small snapshot before the service is ready. After a committed source change, refresh the affected row or publish a new complete snapshot. Also schedule a periodic reload when missed events must be repaired.
TransactionalWarm the bounded working set expected to receive traffic. Keep read-through as the correctness fallback for an unexpected miss. After every committed insert, update or delete, refresh or evict that key. Keep a short, externally configured TTL as the final safety net.

If “a client request must never call the source” is a hard requirement: configure that region as CACHE_ASIDE, warm all required keys before marking the instance ready, and treat an unexpected cache miss as a controlled unavailable response. Do not describe that setup as read-through. The application owns the readiness check because it owns the warm-up query.

Failures and instance lifecycle

what happenswhat consumers seehow it recovers
A service instance diesNo session state is lost. Requests move to another instance; its L1, shared L2 or source answers.The replacement instance fills its own L1 as it receives requests.
Redis is unavailable at startupThe application still starts. Cache regions are disabled and cache-aware facades use their normal source.Restart after Redis is reachable. Startup failure does not retry silently in the background.
Redis fails while the service is runningL1 hits remain immediate. On an L1 miss, the request waits up to the configured Redis timeout and then a read-through region calls its source. The source result is kept in that instance's L1 even if L2 cannot be updated.Redis reads and writes work again when the connection recovers. Monitor the warnings and source-load rate.
A refresh consumer dies after receiving an eventThe previous cached value may be served while the event is pending.Redis Streams retains the unacknowledged event; another instance claims it after claim-min-idle.
A refresh loader failsThe previous value remains available; readers do not wait for the refresh job.The event remains pending and is retried. TTL, scheduled reload and an operator refresh provide additional repair paths.
The producer never publishes the eventThe cache cannot know that the source changed, so the value can be stale.Transactional TTL limits the stale period. Static regions need a scheduled or operator reload. Use a transactional outbox when event delivery must be guaranteed with the database commit.

Secondary-index lookups cannot rebuild an index from one primary-key loader call. If Redis is unavailable, they return a cache miss so the application facade can run its indexed source query. Keep that source fallback in the facade.

Static data guide

Use static caching for a small reference table that can be loaded as one complete snapshot—for example countries, status codes or product categories. Each service instance keeps a temporary L1 copy, Redis or Valkey holds the shared snapshot, and the repository remains the fallback source.

Full static implementationProperties → entity → repository → snapshot configuration → service → controller Open code guide →

What a snapshot is — and what it is not

Every value in the cache is a snapshot: a small, read-only copy of one database row, stored as JSON in Redis and as an object in each instance's memory. Both static and transactional regions cache snapshots — the difference is only how they are loaded (whole table at once, or one row when first read).

Why the cache stores a snapshot and 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) Serializes to JSON and back without surprises; cannot be modified; equal by content
Built by one static method 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 serialization, 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
Changing its fields is a change other instances and applications see See "Changing a snapshot safely" below

Changing a snapshot safely

Instances are redeployed one at a time, so for a while old and new code read the same Redis values. Other applications may share a region too. This makes the snapshot class a contract, and there are two kinds of change:

Not the same as the "Memento" design pattern

Because the name is "snapshot", the GoF Memento pattern comes to mind. It is a different thing. A memento is a saved copy of an object's state made so the same object can be restored to it later — undo, rollback. Our snapshots are never restored into anything: data flows one way, from the database through the loader into the cache and out to readers. When the source changes we do not put the old snapshot back, we evict the entry or load a new one from the database. The right description of what we cache is: an immutable read-only copy of a row (a value object), produced by a mapping method, that the cache stores under a key — the same idea as a "read model" or "projection" in systems that separate writes from reads.

Writing these records by hand for every entity is repetitive; the cache-snapshot-processor module generates them from the entity class at compile time. See Generating snapshots at the end of this page.

Transactional data guide

Use transactional caching for data that changes continuously. Load a bounded useful set, retain the one-row database loader for an unexpected miss, and register one Hibernate write binding for the entity. Normal JPA inserts, updates and deletes then evict the affected key after commit. A relevant application event can select rows when a time window is unsuitable.

Full transactional implementationProperties → entity → repository → loader and region → service → controller Open code guide →

Settings shared by both data types

Tuning without code changes

Keep safe defaults in code. Override sizes, expiry times and refresh schedules in application.yaml when an environment needs different values:

dac:
  cache:
    # refresh-cron: "0 */5 * * * *"     # scheduled refresh of all static regions
    regions:
      productCategories:
        l1-max-size: 5000
        startup: reload-if-stale        # reload at startup when the copy is older than…
        max-snapshot-age: 24h
      transactions:
        l1-max-size: 1000
        l2-ttl: ${TRANSACTION_CACHE_TTL:5m}
        negative-ttl: ${TRANSACTION_NEGATIVE_TTL:30s}

The values declared in Java are safe defaults. The YAML values above win at startup, so each environment can tune the TTL and size without rebuilding the application.

Try it in the live initializer →

Shared rules and optional behaviours

You do not need every feature for every table. Start with the simplest option that keeps your data correct, then add another only when there is a clear reason.

Static data: preloaded and versioned

Use this for a small table that changes rarely. Load the whole table at startup. Reads then use the cached snapshot by id or by an index such as product code. When the source changes, load a new snapshot and switch to it only after the load succeeds.

Try it in the live initializer →

Refresh & partial refresh

A database change does not magically change a cached copy. Call the refresh endpoint when you want to reload one static region, or refresh all static regions together.

Try it in the live initializer →

Event-driven refresh

Use a refresh event when another service or batch job changes the source. The event only needs a region name and key. One instance reloads that row and the other instances drop their old local copy. Redis Streams keeps the event until it succeeds; a failed refresh remains pending for retry. If the producer fails before appending the event, Redis has nothing to deliver, so TTL or a scheduled reload is still required. Use an outbox when the source commit and event must be guaranteed together.

Try it in the live initializer →

Loading transactional data

Warm the bounded working set expected to receive traffic. The per-key loader is a correctness fallback: if a row was not warmed, the first read gets it from the database and caches it. When a time window is not useful, let a relevant application event select and cache the required rows. In this initializer, every cached row expires after 30 seconds; after that, the next read loads it again.

Try it in the live initializer →

How transactional data stays fresh

After data changes, use the first option that fits the write path. Keep TTL as a fallback, not as the main refresh method.

#what to douse it whenresult
1Register CacheWriteBinding.evict(...) once normal Hibernate/JPA entity writesinsert, update and delete evict automatically after commit
2Call CacheFreshness inside the transaction bulk JPQL or native SQL bypasses entity listenersthe action waits for commit and is dropped on rollback
3Publish a refresh event with the region and key another service or batch job owns the writethat row is reloaded
4Update through the cache with region.put(...) many rapid updates should be batchedcache changes now; DB flushes later
5Let the TTL expire an earlier refresh was missedthe stale copy eventually disappears

Simple rule: use the Hibernate binding for normal entity writes and CacheFreshness for write paths that bypass Hibernate. TTL only limits how long a missed refresh can cause stale data.

Try mechanisms 1, 2 and 5 in Staleness & eviction, mechanism 3 in Event-driven refresh, and mechanism 4 in Batched writes.

Choose a safe cache key

Use the same stable identifier that your database uses for the row.

✓ Do✗ Don't
Evict in every code path that changes the data Update the database directly and hope the TTL fixes it
Cache plain snapshot records Cache JPA entities, or objects you keep changing after put
Key by the row's stable id Build keys from values that can change (names, statuses)
Declare a secondary index for lookups by business fields Concatenate composite string keys by hand
Keep TTLs modest — seconds to minutes for data that changes Raise the TTL to improve the hit ratio of changing data
Use negativeTtl when absent keys are requested often Let every request for a missing row hit the database
Use technical identifiers in keys Put names, IBANs or any client data into a key
Treat the cache as a copy — every value must be reloadable from its source Treat the cache as a database or the only home of a value

Caching "not found"

If clients repeatedly ask for an id that does not exist, remember that result briefly. In this initializer, the cache remembers “not found” for 15 seconds, so the database is asked only once.

Try it in the live initializer →

Staleness & eviction

Stale data means the database changed but the cached copy did not. Fix the write path: after the database commit, evict the key or publish a refresh event. The next read then gets the new value.

Try it in the live initializer →

Write policies: write-behind, write-through, write-around

Choose one write rule per region:

Try it in the live initializer →

Leader election

Some background work should run once, even when several service instances are running. The instances use a short Redis lock to choose an owner. If that instance stops, another takes over.

Try it in the live initializer →

Statistics

Watch hits, misses, database loads and evictions. These numbers tell you whether warming and TTL settings are helping. They are also available as dac.cache.* actuator metrics.

Try it in the live initializer →

Installing Redis/Valkey on premise

The cache needs one shared Redis-compatible store in each environment. Valkey is the recommended option and works with Spring's existing spring.data.redis.* settings.

Choose the setup that matches where the application runs:

1. Local development

The initializer works without an installation. To test against a real Valkey instance, run:

docker run -d -p 6379:6379 valkey/valkey:8.0

Then set spring.data.redis.host: localhost. This single-node setup is only for local development.

2. On-premise Kubernetes — the CNH high-availability chart

For shared environments, this repository provides a Helm chart: dac/pkgs/valkey-sentinel-chart — the same chart used by the CNH on-premise test environment. It gives you:

Installation:

helm install valkey dac/pkgs/valkey-sentinel-chart \
  --namespace valkey --create-namespace \
  --set auth.password=<a-strong-password> \
  --set persistence.size=2Gi

Applications connect through Sentinel, so they can find the current primary after a failover:

spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes: valkey:26379
      password: <the valkey password>

The settings most installations touch:

valuedefaultmeaning
replicaCount3number of nodes (each pod = one Valkey + one watchdog); keep it odd, minimum 3, so votes cannot tie
auth.password / auth.existingSecretthe password, set inline or taken from an existing Kubernetes Secret
sentinel.quorum2how many watchdogs must agree that the primary is down before a failover starts
persistence.size2Gidisk per node for the journal
valkey.extraConfigextra server settings, e.g. maxmemory 256mb
primaryProxy.enabledfalsethe traffic gate for clients outside the cluster; such clients use plain host/port configuration instead of Sentinel

3. Production checks

Start with replication and Sentinel. Move to Redis Cluster sharding only when one node can no longer hold the data or handle the write rate.

Generating snapshots

Every cached entity needs a snapshot record and a from(entity) method. The code is simple but repetitive, and it has to be kept in step with the entity. The module cache-snapshot-processor writes it for you at compile time, the way Lombok or MapStruct do. It is an annotation processor: it runs inside javac and ships nothing at run time.

What you write

@Entity
@Data                                                    // Lombok getters are fine
@CacheSnapshot(
        region  = "productCategories",
        type    = RegionKind.STATIC,                     // or TRANSACTIONAL
        indexes = @SnapshotIndex(name = "byName", property = "name"))
public class ProductCategory {

    @Id private Long id;
    private String name;
    private String description;

    @SnapshotIgnore                                      // stays out of the cache
    private String internalComment;

    @ManyToOne(fetch = FetchType.LAZY)                   // becomes familyId
    private ProductFamily family;

    @OneToMany(mappedBy = "category")                    // collections are always left out
    private List<ProductTag> tags;
}

What the processor generates

// ProductCategorySnapshot.java — in target/generated-sources, do not edit
@MasterDataRegion(name = "productCategories", entity = ProductCategory.class,
        indexes = @RegionIndex(name = "byName", property = "name"))
public record ProductCategorySnapshot(
        Long id, String name, String description,
        Long familyId) {                                 // relation flattened to its id

    public static final String REGION = "productCategories";

    public static ProductCategorySnapshot from(ProductCategory entity) { ... }
}

The record lands in the entity's package. For a static region it carries @MasterDataRegion, so declaring the cache needs no further code. For a transactional region it is a plain record; you still write the region config and loader as shown above.

The rules

situationwhat the generator does
Plain column (number, text, date, enum, boolean)Copied as a record component. Validation annotations on the entity field (@NotNull, @Size) are not copied.
Fields of a @MappedSuperclass (audit columns)Left out — only the entity's own fields are read.
@ManyToOne / @OneToOneOnly the related row's id is copied, as <name>Id, through a null-safe reader.
@OneToMany / @ManyToMany / any collectionLeft out. Collections are the main cause of oversized cache entries; cache the related table in its own region.
@SnapshotPath(name, path)Copies one value from a related row along a getter chain, e.g. "segmentMapping.eventConfiguration.eventType". Every step is null-checked. The path must cross at least one relation.
implement = SomeInterface.classThe record implements the interface. Put computed lookup keys there as default methods and their builders as static methods; an index may point at such a method.
@SnapshotIgnoreField never reaches the cache — for internal columns and anything client-identifying.
Something the rules cannot expressWrite the record by hand as before; the annotation is optional everywhere.

Computed keys: an example

public interface EventTemplateKeys {
    Long alertChannelMappingId();                        // satisfied by the record's accessors
    Long eventSegmentsMappingId();
    LanguageCode language();

    default String channelSegmentLanguageKey() {         // an index can point at this
        return alertChannelMappingId() + "|" + eventSegmentsMappingId() + "|" + language();
    }
}

@CacheSnapshot(region = "eventTemplate", implement = EventTemplateKeys.class,
        indexes = @SnapshotIndex(name = "byChannelSegmentLanguage", property = "channelSegmentLanguageKey"),
        paths   = @SnapshotPath(name = "eventType", path = "eventSegmentsMapping.eventConfiguration.eventType"))
public class EventTemplate { ... }

Wiring it into a build

<!-- the annotations: compile time only, like Lombok -->
<dependency>
  <groupId>com.juliusbaer.dac</groupId>
  <artifactId>cache-snapshot-processor</artifactId>
  <scope>provided</scope>
</dependency>
<!-- @MasterDataRegion on the generated static records -->
<dependency>
  <groupId>com.juliusbaer.dac</groupId>
  <artifactId>cache-masterdata-starter</artifactId>
</dependency>

<plugin>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
      <path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></path>
      <path><groupId>com.juliusbaer.dac</groupId><artifactId>cache-snapshot-processor</artifactId></path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Put the processor in the module that holds the entities. If a snapshot was written by hand before, delete it in the same change — two classes declaring the same region name fail at start-up.

Why compile time, and not runtime

Not done yet