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/:

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 + per-domain flags
dac/libs/cache-domain-castshared CAST user-data regions (optional)
dac/svcs/dac-demothis 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 containswhat for
cache-corethe engine: regions, two cache levels, TTLs, eviction, versioned refresh, leader election, metrics
cache-binding-springevicting a key from a service that only writes the data; per-domain on/off flags
cache-masterdata-starterstatic 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.

Your application (each instance) Your code services · controllers · jobs DAC Cache (library, in-process) Level 1: in-memory cache (per instance) regions · lookups by id and by index TTLs · eviction · refresh · statistics read / write Redis / Valkey Level 2: shared cache used by all instances and services also carries eviction messages Database / remote source read only when both cache levels miss miss → read L2 / store still missing → load Other instances / other services same library, same Redis — a value cached by one is a hit for all data changed → evict key, all instances drop it Behaviour Static data whole table loaded once, kept until a refresh replaces it Transactional loaded per key on first read, expires after the TTL Eviction writers delete the key immediately; TTL is the backup Partial refresh reload ONE static region on demand, the rest untouched POST /api/admin/ …/reload/{region}
cache: checking… keys: —

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.

Per-region counters