Getting started
DAC Cache is a caching library for Spring Boot services. It keeps data in two levels — a small in-memory cache inside each instance, and a shared Redis/Valkey that all instances use — and loads from your database or remote source only when neither level has the value.
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 + per-domain flags |
| dac/libs/cache-domain-cast | shared CAST user-data regions (optional) |
| dac/svcs/dac-demo | this demo |
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.
Integration is three steps:
1. Add the dependency
One dependency covers everything below — static data, transactional data, eviction and the refresh endpoints:
<dependency>
<groupId>com.juliusbaer.dac</groupId>
<artifactId>cache-starter</artifactId>
</dependency>
| it contains | what for |
|---|---|
| cache-core | the engine: regions, two cache levels, TTLs, eviction, versioned refresh, leader election, metrics |
| cache-binding-spring | evicting a key from a service that only writes the data; per-domain on/off flags |
| cache-masterdata-starter | static tables from an annotation, the MasterDataAccess facade, the refresh endpoints |
Nothing else is required. Shared domain packages (for example the CAST user data used inside CNH) are separate, optional dependencies added on top.
2. Point at Redis
spring:
data:
redis:
host: my-redis # the only required configuration
Without a Redis connection the cache simply stays off: the application starts normally and every read goes to its source. Nothing breaks.
3. Declare what to cache
For a static table (reference data that rarely changes), annotate a snapshot record. The library finds the entity's Spring Data repository, loads the whole table at startup and keeps it cached:
@MasterDataRegion(name = "docTypes", entity = DocType.class, indexes = {
@RegionIndex(name = "byCode", property = "code")})
public record DocTypeSnapshot(Long id, String code, String displayName) {
public static DocTypeSnapshot from(DocType e) { ... }
}
Then read it anywhere:
masterDataAccess.byIndex(DocTypeSnapshot.class, "byCode", "PDF"); // Optional<DocTypeSnapshot>
For transactional data (data that changes at runtime), declare one bean with a loader and a TTL, and delete the key whenever you change the data:
@Bean
CacheRegionConfig<String, GreetingSnapshot> greetings(GreetingStore store) {
return CacheRegionConfig.transactional("greetings", String.class, GreetingSnapshot.class)
.loader(user -> Optional.ofNullable(store.peek(user))) // called on a cache miss
.l2Ttl(Duration.ofMinutes(5)) // safety net
.build();
}
// in every code path that changes the data:
region.remove(user); // same service
regionEvictions.evict("greetings", user); // from another service
That is the whole integration. Everything below demonstrates the behaviour live against this running instance.
How it fits into an application
The library sits between your code and your data sources. Your code always calls the cache; the cache decides whether the answer comes from memory, from Redis, or from the source.
- Static data — loaded completely at startup and stored under a version number. A refresh loads a complete new version and switches to it in one step, so readers never see a half-loaded set.
- Transactional data — loaded per key on the first read and kept for the TTL. Every code path that changes the data deletes the key (eviction); the TTL only limits how long a missed eviction can last.
- Partial refresh — one static region can be reloaded on its own
(
POST /api/admin/master-data-cache/reload/{region}) without touching the others. - Event-driven refresh — whoever changes the source appends
{region, key}to the application's refresh channel (a Redis Stream); one instance re-loads exactly that key. No endpoint call, no full reload — see the panel in the refresh section.
docker run -p 6379:6379 valkey/valkey:8.0) and restart the demo to try all panels.
Static data
The product table is cached as a static region. Lookups work by id or by a declared index:
byCode maps one code to one product, byCategoryId maps a category to all
its products.
Look up products from the cache
Refresh & partial refresh
Changing the source does not change the cache — the cached set stays as it is until a refresh. A refresh loads a complete new copy, then switches over. You can refresh one region on its own (partial refresh) or all of them; both are plain HTTP endpoints every application gets automatically.
Change the source, then refresh
The docTypes region below can be refreshed on its own with
POST /api/admin/master-data-cache/reload/docTypes — that is the partial refresh.
Partial refresh of one region (docTypes)
Both refreshes above are endpoints someone must call. The channel below removes even that step — and it refreshes a single key, not a whole region.
Event-driven refresh — one key, no endpoint
Every application using the library listens on its own refresh channel — a Redis Stream at
<root>:channel:refresh. Any system that changes source data appends a small event
(region + key): from Java via the auto-configured
RefreshEventPublisher, from anywhere else with a plain XADD. Exactly one
instance picks it up and re-loads only that key through the region's loader, under a short
per-key lock so an event burst cannot stampede the database; every other instance drops its
in-memory copy. Readers are never blocked — they serve the old value until the new one is in place.
Master data (docTypes) — the snapshot is updated in place, the version does not move:
Transactional (greetings) — repair a missed eviction without waiting for the TTL:
The consumer polls twice a second — if step 3 still shows the old value, click it again. An event without a key refreshes the whole region: versioned regions reload their snapshot, transactional ones drop every entry.
Transactional data (read-through + TTL)
Data that changes at runtime is loaded per key: the first read goes to the source, the answer is
cached, later reads are served from memory until the TTL expires or the key is evicted. Watch
sourceLoaderCalls — it increases on the first read of a user, then stops.
Read a user's greeting (TTL: 1 minute)
Caching "not found"
Keys that do not exist can be requested just as often as ones that do. The region remembers "this key has no value" for a short time (30 seconds here), so repeated misses stop hitting the source. Read an unknown user twice: the loader count rises once, then stays.
Read a user that does not exist
Eviction on write
A cached value is kept correct by deleting the key whenever the data changes. The deletion reaches Redis and every instance's in-memory cache. The TTL is only the backup for a forgotten eviction — the second button shows exactly that mistake: the cache keeps serving the old value until the TTL runs out.
A service that only writes the data (and never registered the region) evicts too, with one
call: regionEvictions.evict("demoGreetings", user).
Change data with and without eviction
Batched writes (write-behind)
For keys that change very often (status fields), the region can queue writes instead of hitting the database each time: updates go to a durable queue in Redis, and one instance writes them to the database in batches every 2 seconds — keeping only the newest value per key. 50 updates become one database write. Queued entries survive an instance crash.
50 updates → 1 database write
Leader election
Some work must run on exactly one instance: loading a static region at startup, flushing the write queue, scheduled refreshes. The instances coordinate through a lock key in Redis with an expiry; whoever holds it does the work. If that instance dies, the lock expires (within 30 seconds) and another instance takes over. No extra infrastructure is needed.
Which work does this instance currently own?
Configuration
Code declares what a region is; sizes, TTLs and refresh behaviour are configuration.
Values set in application.yaml override the code defaults — no code change needed to
retune a region. This demo configures the docTypes region like this:
dac:
cache:
# refresh-cron: "0 */5 * * * *" # scheduled refresh of all static regions
regions:
docTypes:
l1-max-size: 5000
startup: reload-if-stale # reload at startup when the copy is older than…
max-snapshot-age: 24h
Effective settings per region
Statistics
Every region counts hits, misses, loads and evictions. The same numbers are exported as
metrics (dac.cache.*) under /actuator/metrics.