Data plane — the cache facade
The data plane is the part of the library your application touches, and
AbstractCache is its primary entry point: the class an application subclasses to own a region.
It replaces the @Bean CacheRegionConfig plus hand-written lookup pattern with one
typed class that registers its own region and carries the cache-plane-off fallback, the domain
flag, the transaction guards and the logging context on every call. Underneath it sits the cache
plane (the engine) — the tiers, versions and machinery your code never talks to directly.
Concept
A facade is three things put together:
- A
RegionBinding— a pure description of the region: its name, key and value types, kind (static or transactional), default TTL, secondary indexes, optional domain and what each row belongs to. It has no Spring and no engine in it, which is why a generated snapshot can carry one as a constant. - A
CacheSupport— the facade's only link to the outside: "give me this region if it exists and is loaded", "is this domain on", "is there a TTL override", "run this read in a read-only transaction", "run this write after commit". Spring providesSpringCacheSupport; tests useCacheSupport.none(). - Your subclass — names the binding, optionally overrides a hook (
loader(),writer(),customise(),loadFromSource()…) and adds the domain-specific read methods your service calls.
At startup FacadeRegionConfigSource collects every AbstractCache bean,
asks each for its regionConfig() and hands the result to the engine. An explicit
@Bean CacheRegionConfig with the same region name always wins, so facades can be
introduced next to existing configuration without conflict.
The minimal facade
A region that is loaded by the master-data starter (a JPA entity with @CacheSnapshot)
needs nothing but the binding and the read methods you want to expose. This is the whole class for
eventConfig in indc-core:
@Component
public class EventConfigurationCache extends AbstractCache<Long, EventConfigurationSnapshot> {
private final EventConfigurationRepository repository;
public EventConfigurationCache(CacheSupport support, EventConfigurationRepository repository) {
super(EventConfigurationSnapshot.BINDING, support);
this.repository = repository;
}
public Optional<EventConfigurationSnapshot> byEventType(String eventType) {
return byIndex("byEventType", eventType)
.or(() -> repository.findByEventType(eventType).map(EventConfigurationSnapshot::from));
}
}
When the region is loaded, byIndex answers from L1 or L2. When it is not — engine
off, Redis down, or a pod that has not attached to a version yet — byIndex returns
empty and the .or(…) branch runs the indexed query. The service that calls
byEventType never knows which happened.
Describing a region: RegionBinding
Hand-build a binding when the value is not a generated snapshot — a DTO from an external system, a record you wrote, a wrapper around a list:
@Component
public class IdmUserCache extends AbstractCache<String, User> {
static final RegionBinding<String, User> BINDING = RegionBinding
.transactional("idmUsers", String.class, User.class)
.ttl(Duration.ofMinutes(30))
.domain("idm-user", true)
.localFallback(1_000)
.build();
public IdmUserCache(CacheSupport support) {
super(BINDING, support);
}
}
| builder method | meaning |
|---|---|
| staticRegion(name, K, V) | A versioned, preloaded region. No TTL; put() is ignored. |
| transactional(name, K, V) | A per-key region with a TTL. |
| .ttl(Duration) | Default L2 TTL. Overridable per environment by dac.cache.regions.<name>.l2-ttl; l2Ttl() returns the effective value. |
| .domain(name) .domain(name, enabledByDefault) | Puts the region under the Redis root dac:<name> and the flag dac.domains.<name>.enabled. The second form sets what the flag means when it is absent. |
| .index(name, V → key) .multiValuedIndex(name, V → key) | Secondary indexes. Single-valued answers byIndex; multi-valued answers listByIndex. |
| .localFallback(maxSize) | A bounded in-process Caffeine cache (expiring after the effective TTL) used when the engine is off or the region is absent. Turns "Redis is down" into "each pod caches for itself" instead of "every call hits the source". |
| .scope(DacScope<V>) .perPrincipal() | Access-control metadata used by AbstractDacCache — see Access control. |
Prefer the generated <Entity>Snapshot.BINDING whenever the value comes from a JPA entity: the annotation on the entity is then the single place where region name, kind, TTL, indexes, domain and scope are declared. See Snapshots & @CacheSnapshot.
Reading
| method | region loaded | region absent |
|---|---|---|
| get(key) | L1 → L2 → loader (read-through) or miss (cache-aside) | loadFromSource(key), through the local fallback if configured |
| getOrLoad(key, supplier) | L1 → L2 → supplier; a non-null result is written to the region | supplier, through the local fallback if configured |
| getAll(keys) | get per key, in order, present values only | same, via source |
| byIndex(index, value) | first entry of the index | first of loadFromSourceByIndex(index, value) |
| listByIndex(index, value) | every entry of the index | loadFromSourceByIndex(index, value) |
| isEnabled() | whether a loaded region is behind this facade right now | |
Every read passes its result through afterRead(key, value) before returning it —
the hook AbstractDacCache uses to enforce entitlements. A null key or index
value always returns empty without touching anything.
Read-through or cache-aside?
If the subclass overrides loader(), the region is read-through: the engine calls
the loader on a miss, inside one pod, and the others pick the value up from L2. If it does not, the
region is cache-aside and the application supplies the value with getOrLoad. Use
cache-aside when the source call needs request-scoped context — an HTTP header, the caller's
token — that a loader bean would not have:
public User getUsersById(String userId) {
return idmUserCache
.getOrLoad(userId, () -> fetch(() -> jbIdmWebClient.getUsersById(userId, getHttpHeaders())))
.orElse(null);
}
Writing and refreshing
| method | effect |
|---|---|
| put(key, value) | Transactional regions: writes to the region after the surrounding transaction commits (immediately when there is none). Static regions: ignored with a warning — replace the version with refresh() instead. |
| remove(key) | Drops the local-fallback entry now and the region entry after commit; the engine's invalidation bus clears every other pod's L1. |
| refresh() | Reloads the whole region (static: a new version, switched in when complete). |
| refreshKey(key) | Reloads one key through the loader, after commit. |
| evictLocal(key) | Drops only this pod's local-fallback copy. |
The after-commit guard is what makes it safe to call put or remove from inside a @Transactional service method: if the transaction rolls back, the cache never saw the write. SpringCacheSupport registers a TransactionSynchronization when one is active and runs the write directly otherwise.
Hooks
| hook | default | override when |
|---|---|---|
| loader() | empty → cache-aside | the engine should load misses itself (read-through), and for static regions not loaded by the master-data starter (supply loadAll()) |
| writer() | empty → WritePolicy.NONE for transactional regions | the region writes to the database (write-through / write-behind) |
| customise(builder) | returns the builder unchanged | you need an engine option the binding does not express: namespace, l1Ttl, negativeTtl, write-behind options |
| loadFromSource(key) | empty | the facade must still answer when the region is absent — almost always |
| loadFromSourceByIndex(index, value) | empty list | same, for indexed reads |
| afterRead(key, value) | returns the value | you need a per-read decision; AbstractDacCache already implements it |
A complete transactional facade with a loader, a writer and write-behind batching — the
transactions region from the live initializer:
@Override
protected Optional<CacheLoader<String, TransactionSnapshot>> loader() {
return Optional.of(ledger);
}
@Override
protected Optional<CacheWriter<String, TransactionSnapshot>> writer() {
return Optional.of(ledger);
}
@Override
protected CacheRegionConfig.Builder<String, TransactionSnapshot> customise(
CacheRegionConfig.Builder<String, TransactionSnapshot> builder) {
return builder
.write(WritePolicy.WRITE_BEHIND)
.writeBehind(WriteBehindOptions.builder().flushEvery(Duration.ofSeconds(2)).coalesceByKey().build())
.l1Ttl(Duration.ofSeconds(30))
.negativeTtl(Duration.ofSeconds(15));
}
What the facade guarantees
- It trusts only loaded regions.
region()filters onCacheRegion.isLoaded(), so a static region with no live version is treated as absent. A non-leader pod that starts before the first snapshot is published reads from its source instead of from nothing. - Source reads run read-only.
CacheSupport.readFromSourcewraps every fallback in a read-onlyTransactionTemplatethat joins an active transaction and opens a short one otherwise, so lazy relations infrom(entity)are safe. - Cache writes wait for commit.
put,removeandrefreshKeygo throughafterCommit. - Domain off means source. A binding with a domain whose flag is off behaves exactly as if the engine were off — and skips the local fallback too, so "off" really means "every read goes to the source".
- Every call is logged in context. Operation, region and domain go into the MDC for the duration of the call.
- A static region is never patched.
put()on a static binding is a no-op with a warning.
Testing a facade
A facade with CacheSupport.none() has no region and no domain — every read goes to the
source path, which is usually what a unit test of the surrounding service wants:
@Spy
private IdmUserCache idmUserCache = new IdmUserCache(CacheSupport.none());
To test the cached path, build a DacCacheManager on an embedded backbone and pass a
CacheSupport that returns its region — FacadeTestSupport in
cache-core's tests shows the pattern.
Migrating from engine beans or Spring @Cacheable
| before | after |
|---|---|
@Bean CacheRegionConfig<K,V> region(…) + a lookup helper that checks manager.hasRegion(…) | one AbstractCache subclass; delete the bean and the helper |
@Cacheable("idmUsers") on a service method + a CacheManager config with per-cache TTLs | IdmUserCache.getOrLoad(id, () -> …) at the call site; TTL on the binding, overridable by dac.cache.regions.idmUsers.l2-ttl; the cache config class disappears |
| a feature toggle per cache in application properties | .domain("idm-user", true) → dac.domains.idm-user.enabled |
| in-memory-only cache per pod | .localFallback(n) keeps that behaviour when the engine is off; with the engine on the value is shared across pods |
The regions reference lists every facade CNH runs today, including the eight external-lookup caches that replaced indc-core's Spring cache configuration.