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.
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 contains | what for |
|---|---|
| cache-core | does the actual caching: memory, Redis/Valkey, expiry, loading and metrics |
| cache-binding-spring | keeps cached rows fresh when your service inserts, updates or deletes data |
| cache-masterdata-starter | loads 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>
Step 2 — Point at Redis
spring:
data:
redis:
host: localhost
connect-timeout: 250ms # example: fit these limits to your request SLA
timeout: 500ms
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");
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.
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
- See everything live — every behaviour above runs on the separate live initializer page, with the data shown as tables and a live log.
- Where the cache sits in the network — How it fits into an application (DMZ, BareNet, database, third-party systems).
- Static data — follow the static data guide.
- Transactional data — follow the transactional data guide.
- Keeping caches fresh — refresh endpoints and event-driven refresh for when the source changes.
Where the code lives
The library is developed in the
client-notification-handler
repository under dac/:
| module | what it is |
|---|---|
| dac/libs/cache-core | the engine |
| dac/libs/cache-masterdata-starter | static tables from an annotation + the admin endpoints |
| dac/libs/cache-binding-spring | writer-side eviction and optional feature switches |
| dac/svcs/dac-initializer | this 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.
It asks the cache region for one key.
A hit returns from this service instance.
A hit is shared by all service instances.
Read once, cache the result, then return it.
Compatibility
The current release has one tested baseline. Use these versions unless the library is updated and successfully tested with another combination.
| part | supported baseline | what this means |
|---|---|---|
| Java / JDK | 25 | Build and run the library with JDK 25. |
| Spring Boot | 4.0.6 | This is the version used to build and test every DAC module. |
| Spring Framework and Spring Data | Managed by Spring Boot 4.0.6 | Keep Spring Boot dependency management instead of overriding these versions separately. |
| Hibernate write binding | Hibernate ORM managed by Spring Boot 4.0.6 | Normal JPA inserts, updates and deletes are observed automatically. Bulk JPQL or native SQL still needs an explicit CacheFreshness call. |
| Redis / Valkey | Redis-compatible server; examples use Valkey 8.0 | The library connects through Spring Data Redis and Lettuce. |
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.
- Read order is always the same: L1 in this pod, then L2 in Valkey, then the loader reads the source. Each hit is a database or third-party call that did not happen.
- Any pod can serve any request. L1 is only a temporary local copy, so no session affinity or sticky routing is needed. A pod that dies loses only its own L1.
- One Valkey does three jobs: it stores the shared values, carries the eviction messages that clear every pod's L1, and holds the refresh-event stream other systems write to. No extra middleware.
- 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.
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.
The recommended application pattern
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 type | required loading pattern | required freshness rule |
|---|---|---|
| Static | Load 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. |
| Transactional | Warm 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 happens | what consumers see | how it recovers |
|---|---|---|
| A service instance dies | No 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 startup | The 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 running | L1 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 event | The 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 fails | The 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 event | The 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
- An entity is tied to a database session. It carries proxies and lazy relations that only work while that session is open. Serialize one into Redis and read it back in another instance and it fails or drags along data nobody asked for. A snapshot is plain fields only.
- An entity can be modified. Setters, and Hibernate tracking every change. A cached
value is shared by every thread in the instance and by every instance; if one caller could
change it, all readers would see the change. A snapshot is a Java
record: it cannot be modified after it is created. - An entity has the shape of the table. A snapshot has the shape the readers need — usually flatter, sometimes with a value pulled from a related table, never with fields nobody reads. This keeps Redis small and reads fast.
The rules a snapshot follows
| rule | why |
|---|---|
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:
- Adding a field — safe. Old readers ignore the new field; new readers see
nullfor values written by old code until the entry is reloaded or expires. For static regions the next reload writes a complete new version. - Renaming, removing or changing the type of a field — a breaking change. Treat it like an API change: give the region a new name (which is a new set of keys) or reload it in the same deployment as the code change, and never do it on a region another application reads.
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 →Static data full code
Use this path for a small reference table such as countries, status codes or product categories. The library loads the complete table as one versioned snapshot and readers switch to a new version only after a later reload succeeds.
Complete example: add these files in order. The service keeps the repository fallback visible, so a cache that is off, still loading or temporarily unavailable does not stop the application from reading its source.
1. Properties — application.yaml
spring:
application:
name: portfolio-service
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
dac:
cache:
enabled: true
# Optional: reload all static regions on a schedule.
refresh-cron: "0 */15 * * * *"
regions:
productCategories:
l1-max-size: 5000
startup: reload-if-stale
max-snapshot-age: 24h
l1-max-size applies to the small in-memory copy inside each service instance. It is
temporary: it needs no session affinity, disappears when that instance stops, and is rebuilt from
Redis or the source.
2. Entity — ProductCategory.java
The annotation on the entity is the cache configuration. The build generates the record that is
stored in the cache (next section); the starter finds the repository, loads all rows, maps them with
the generated from(...), and builds the byName lookup. No separate region
bean is needed.
@Entity
@Table(name = "product_category")
@CacheSnapshot(
region = "productCategories",
indexes = @SnapshotIndex(name = "byName", property = "name"))
public class ProductCategory {
@Id
private Long id;
private String name;
private String description;
protected ProductCategory() { }
public Long getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
}
3. Repository — ProductCategoryRepository.java
public interface ProductCategoryRepository
extends CrudRepository<ProductCategory, Long> {
Optional<ProductCategory> findByNameIgnoreCase(String name);
}
4. Cached value — ProductCategorySnapshot.java (generated)
The cache stores a plain record, never the managed JPA entity. This file is written by the build
into target/generated-sources/annotations; you read it, you do not edit it:
// Generated by cache-snapshot-processor from ProductCategory. Do not edit.
@MasterDataRegion(
name = "productCategories",
entity = ProductCategory.class,
indexes = @RegionIndex(name = "byName", property = "name"))
public record ProductCategorySnapshot(
Long id,
String name,
String description) {
public static final String REGION = "productCategories";
public static ProductCategorySnapshot from(ProductCategory entity) {
if (entity == null) {
return null;
}
return new ProductCategorySnapshot(
entity.getId(), entity.getName(), entity.getDescription());
}
}
The mapping runs in a read-only transaction, so it can safely access lazily loaded
fields. Keep the region name stable because refresh events and external settings use it. If you
prefer to write this record yourself, put @MasterDataRegion on your own record and leave
the entity un-annotated — see Generating snapshots.
5. Service — ProductCategoryService.java
@Service
public class ProductCategoryService {
private final MasterDataLookup<ProductCategorySnapshot> cache;
private final ProductCategoryRepository repository;
public ProductCategoryService(MasterDataAccess masterData,
ProductCategoryRepository repository) {
this.cache = masterData.lookup(ProductCategorySnapshot.class);
this.repository = repository;
}
public Optional<ProductCategorySnapshot> findById(Long id) {
return cache.byId(id).or(() ->
repository.findById(id).map(ProductCategorySnapshot::from));
}
public Optional<ProductCategorySnapshot> findByName(String name) {
return cache.byIndex("byName", name).or(() ->
repository.findByNameIgnoreCase(name).map(ProductCategorySnapshot::from));
}
}
The usual path is L1 → Redis → result. The repository expression is the deliberate safety path. It runs only when the cache cannot answer; the request may wait up to the configured Redis timeout before that fallback starts.
6. Controller — ProductCategoryController.java
@RestController
@RequestMapping("/product-categories")
public class ProductCategoryController {
private final ProductCategoryService service;
public ProductCategoryController(ProductCategoryService service) {
this.service = service;
}
@GetMapping("/{id}")
public ResponseEntity<ProductCategorySnapshot> byId(@PathVariable Long id) {
return service.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<ProductCategorySnapshot> byName(@RequestParam String name) {
return service.findByName(name)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
}
When this table changes, publish a refresh event after the source commit or use the supplied operator/scheduled reload. If commit and event delivery must succeed together, publish through a transactional outbox.
Transactional data full code
Use this for orders, payments or transactions that keep changing. Do not load the whole table. Warm a bounded working set that represents likely traffic, load an unexpected miss by id, and give every cached row a TTL. If a time window is not useful, a relevant application event can select and push a bounded set instead.
1. Properties — application.yaml
dac:
cache:
enabled: true
regions:
transactions:
l1-max-size: 1000
l1-ttl: ${TRANSACTION_L1_TTL:2m}
l2-ttl: ${TRANSACTION_CACHE_TTL:5m}
negative-ttl: ${TRANSACTION_NEGATIVE_TTL:30s}
These are the effective TTL and size settings for the region. The Java configuration below defines the loader and write binding; the starter applies these properties at startup. Each environment can change them without rebuilding. TTL is a repair backstop; normal writes actively evict after commit.
2. Entity and cached value — Transaction.java and TransactionSnapshot.java
type = RegionKind.TRANSACTIONAL generates the same kind of record, without the
@MasterDataRegion annotation: a transactional region is declared by the loader and the
configuration below, not by a scan.
@Entity
@Table(name = "transactions")
@CacheSnapshot(region = "transactions", type = RegionKind.TRANSACTIONAL)
public class Transaction {
@Id
private String id;
private String clientId;
private Instant bookedAt;
private BigDecimal amount;
private String currency;
private String status;
protected Transaction() { }
public String getId() { return id; }
public String getClientId() { return clientId; }
public Instant getBookedAt() { return bookedAt; }
public BigDecimal getAmount() { return amount; }
public String getCurrency() { return currency; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
// Generated by cache-snapshot-processor from Transaction. Do not edit.
public record TransactionSnapshot(
String id, String clientId, Instant bookedAt,
BigDecimal amount, String currency, String status) {
public static final String REGION = "transactions";
public static TransactionSnapshot from(Transaction entity) { ... }
}
3. Repository — TransactionRepository.java
public interface TransactionRepository extends JpaRepository<Transaction, String> {
List<Transaction> findByBookedAtGreaterThanEqual(Instant cutoff);
List<Transaction> findByClientId(String clientId);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Transaction t set t.status = 'ARCHIVED' where t.bookedAt < :cutoff")
int archiveBookedBefore(@Param("cutoff") Instant cutoff);
}
The first query defines the default time window. The second is one example of data selected by a business event. Both are bounded by application rules; neither means “load the full table.” The bulk update is included to show the one path Hibernate entity listeners cannot observe.
4. Loader and cache configuration — TransactionCacheConfig.java
@Configuration(proxyBeanMethods = false)
public class TransactionCacheConfig {
@Bean
CacheLoader<String, TransactionSnapshot> transactionLoader(
TransactionRepository repository) {
return id -> repository.findById(id).map(TransactionSnapshot::from);
}
@Bean
CacheRegionConfig<String, TransactionSnapshot> transactions(
CacheLoader<String, TransactionSnapshot> transactionLoader) {
return CacheRegionConfig
.transactional("transactions", String.class, TransactionSnapshot.class)
.loader(transactionLoader) // unexpected miss -> one DB read
.build(); // TTLs and size come from application.yaml
}
@Bean
CacheWriteBinding<Transaction> transactionWrites() {
// Hibernate observes insert, update and delete. The eviction runs only after commit.
return CacheWriteBinding.evict(
"transactions", Transaction.class, Transaction::getId);
}
}
Why evict? It is the recommended safe default: all instances drop the old value and the
next read loads the committed row. A failed cache action never rolls back the business transaction;
the source fallback and TTL remain available. Use CacheWriteBinding.refresh(...) instead
only when a hot key should be reloaded immediately through the refresh channel. If publishing that
key refresh fails, the SPI falls back to eviction.
5. Service — TransactionService.java
@Service
@Transactional(readOnly = true)
public class TransactionService implements ApplicationRunner {
private static final String REGION = "transactions";
private final TransactionRepository repository;
private final ObjectProvider<DacCacheManager> managers;
private final CacheFreshness cacheFreshness;
public TransactionService(TransactionRepository repository,
ObjectProvider<DacCacheManager> managers,
CacheFreshness cacheFreshness) {
this.repository = repository;
this.managers = managers;
this.cacheFreshness = cacheFreshness;
}
// Default load: warm a bounded time window during application startup.
@Override
public void run(ApplicationArguments args) {
warmDefaultWindow(Duration.ofHours(24));
}
public int warmDefaultWindow(Duration window) {
return region().map(cache -> {
Map<String, TransactionSnapshot> rows = repository
.findByBookedAtGreaterThanEqual(Instant.now().minus(window))
.stream().map(TransactionSnapshot::from)
.collect(Collectors.toMap(TransactionSnapshot::id, Function.identity()));
return cache.warm(rows); // cache only; never writes to the DB
}).orElse(0);
}
// Alternative load: call this for a relevant application event when a time window is not useful.
public int warmForEvent(String clientId) {
return region().map(cache -> {
Map<String, TransactionSnapshot> rows = repository.findByClientId(clientId)
.stream().map(TransactionSnapshot::from)
.collect(Collectors.toMap(TransactionSnapshot::id, Function.identity()));
return cache.warm(rows);
}).orElse(0);
}
// Miss handling: an active region calls transactionLoader; an inactive engine uses the repo.
public Optional<TransactionSnapshot> findById(String id) {
return region().map(cache -> cache.get(id))
.orElseGet(() -> repository.findById(id).map(TransactionSnapshot::from));
}
@Transactional
public TransactionSnapshot insert(Transaction transaction) {
return TransactionSnapshot.from(repository.save(transaction));
}
@Transactional
public Optional<TransactionSnapshot> updateStatus(String id, String status) {
return repository.findById(id).map(row -> {
row.setStatus(status);
return TransactionSnapshot.from(row);
});
}
@Transactional
public boolean delete(String id) {
return repository.findById(id).map(row -> {
repository.delete(row);
return true;
}).orElse(false);
}
// Bulk JPQL and native SQL bypass entity listeners, so schedule freshness explicitly.
@Transactional
public int archiveOlderThan(Instant cutoff) {
int changed = repository.archiveBookedBefore(cutoff);
if (changed > 0) {
cacheFreshness.refreshRegionAfterCommit(REGION);
}
return changed;
}
private Optional<CacheRegion<String, TransactionSnapshot>> region() {
DacCacheManager manager = managers.getIfAvailable();
if (manager == null || !manager.hasRegion(REGION)) return Optional.empty();
return Optional.of(manager.region(REGION, String.class, TransactionSnapshot.class));
}
}
The warm methods query the database only when the cache engine is active. A Redis
runtime failure on a primary-key read waits for the configured Redis timeout, then uses the loader
and keeps the result in that instance's L1 while Redis recovers. Ordinary entity writes contain no
cache code. Only the bulk update calls CacheFreshness; that action also waits for commit
and is discarded if the transaction rolls back.
6. Controller — TransactionController.java
@RestController
@RequestMapping("/transactions")
public class TransactionController {
private final TransactionService service;
public TransactionController(TransactionService service) { this.service = service; }
@GetMapping("/{id}")
ResponseEntity<TransactionSnapshot> get(@PathVariable String id) {
return service.findById(id).map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/cache-events/client/{clientId}")
Map<String, Integer> onRelevantEvent(@PathVariable String clientId) {
return Map.of("cachedRows", service.warmForEvent(clientId));
}
@PostMapping
TransactionSnapshot insert(@RequestBody Transaction transaction) {
return service.insert(transaction);
}
@PatchMapping("/{id}/status")
ResponseEntity<TransactionSnapshot> update(
@PathVariable String id, @RequestParam String value) {
return service.updateStatus(id, value).map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable String id) {
return service.delete(id) ? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
Insert, update and delete all pass through JPA, so the Hibernate write binding evicts the key after a successful commit without cache calls in the controller or ordinary service methods. A rollback produces no eviction. The live examples below let you run the default window, event load, miss, insert, update and delete paths yourself.
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 do | use it when | result |
|---|---|---|---|
| 1 | Register CacheWriteBinding.evict(...) once |
normal Hibernate/JPA entity writes | insert, update and delete evict automatically after commit |
| 2 | Call CacheFreshness inside the transaction |
bulk JPQL or native SQL bypasses entity listeners | the action waits for commit and is dropped on rollback |
| 3 | Publish a refresh event with the region and key | another service or batch job owns the write | that row is reloaded |
| 4 | Update through the cache with region.put(...) |
many rapid updates should be batched | cache changes now; DB flushes later |
| 5 | Let the TTL expire | an earlier refresh was missed | the 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.
- Good: a primary key or stable business id such as
TX-1001. - Avoid: names, statuses or any value that may change.
- Keep personal data out of keys: keys appear in Redis, logs and refresh events.
- Need another lookup? Add a secondary index instead of building a hand-made composite key.
| ✓ 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:
- Write-through: update the database now, then update the cache. This is the easiest option to reason about.
- Write-around: update the database and evict the cache. The next read reloads it.
- Write-behind: update the cache now and batch database writes in the background. Use this only when rapid updates make immediate writes too expensive.
- None: this region is read-only from this application's point of view.
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:
- Three Valkey nodes: one primary accepts writes and two replicas keep copies.
- Automatic failover: Sentinel promotes a replica if the primary stops.
- Disk persistence: the append-only journal protects queued writes and refresh events during restarts.
- Optional tools: HAProxy for clients outside the cluster and a small UI for inspecting keys.
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:
| value | default | meaning |
|---|---|---|
replicaCount | 3 | number of nodes (each pod = one Valkey + one watchdog); keep it odd, minimum 3, so votes cannot tie |
auth.password / auth.existingSecret | — | the password, set inline or taken from an existing Kubernetes Secret |
sentinel.quorum | 2 | how many watchdogs must agree that the primary is down before a failover starts |
persistence.size | 2Gi | disk per node for the journal |
valkey.extraConfig | — | extra server settings, e.g. maxmemory 256mb |
primaryProxy.enabled | false | the traffic gate for clients outside the cluster; such clients use plain host/port configuration instead of Sentinel |
3. Production checks
- Memory: allow headroom, set
maxmemory, and keep the policy atnoeviction. The library already manages entry expiry. - Durability: keep the append-only journal enabled when using write-behind or refresh events.
- Security. Always set a password; never place client-identifying data in keys (see Choosing keys); add TLS where the network zone requires it.
- Sharing: one store can serve several applications because each application gets its own
{application name}:cachekey prefix.
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
| situation | what 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 / @OneToOne | Only the related row's id is copied, as <name>Id, through a null-safe reader. |
@OneToMany / @ManyToMany / any collection | Left 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.class | The 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. |
@SnapshotIgnore | Field never reaches the cache — for internal columns and anything client-identifying. |
| Something the rules cannot express | Write 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
- You can read the generated code. It is normal Java in
target/generated-sources, steps through in a debugger, and needs no agent or bytecode manipulation at run time. - Mistakes surface at build time. A missing id, an index on a property that does not exist, or a path that cannot be resolved fails the compilation with a clear message.
- Nothing extra ships. The processor is a build-time dependency only.
- It stays optional. Hand-written snapshots keep working unchanged; a team can adopt the annotation entity by entity.
Not done yet
- Generated write bindings for transactional entities (today:
CacheWriteBindingby hand). - A build-time check that a region name is unique across the whole application.
- A build-time description of each record's shape, so a later build can flag a renamed or removed field of a shared region as a breaking change.