Transactional data
Use a transactional region for data that keeps changing — user alerts, transactions,
devices, lookups against external systems. Rows are cached one at a time with a TTL, kept fresh by
the write path, and the facade's transaction guards make it safe to read and write the cache from
inside @Transactional code. Lookups against an external application (IDM, PDI,
PAS, LDAP) are transactional regions too — but there caching is optional: you never see the
owner's writes, so freshness is TTL-driven and the region should sit behind a flag
(whose data is it).
Concept
Do not copy a large table into the cache. A transactional region holds the rows that were actually asked for (read-through or cache-aside), optionally a warmed working set, and nothing else. Three things keep it correct:
- A TTL on every entry — declared in code as the default, tunable per environment. It is the safety net, not the freshness mechanism.
- An eviction or refresh on every write path — the Hibernate write binding for JPA writes,
CacheFreshnessfor bulk SQL, refresh events for writers in other services,put/removeon the facade. - Source fallback — with the engine off or the domain disabled, every read goes to the source, through a bounded in-process cache if the binding declares one.
Declaring the region
Through the annotation when the value is a JPA entity, or a hand-built binding otherwise. Both
produce the same RegionBinding:
@Entity
@CacheSnapshot(region = "transactions", type = RegionKind.TRANSACTIONAL, ttl = "PT5M")
public class Transaction { … }
@Component
public class UserDevicesCache extends AbstractCache<String, UserDevicesSnapshot> {
static final RegionBinding<String, UserDevicesSnapshot> BINDING = RegionBinding
.transactional("userDevices", String.class, UserDevicesSnapshot.class)
.ttl(Duration.ofMinutes(5))
.localFallback(10_000)
.build();
private final IdmUserDevicesLoader loader;
public UserDevicesCache(CacheSupport support, IdmUserDevicesLoader loader) {
super(BINDING, support);
this.loader = loader;
}
public List<KeyValuePair> devices(String userId) {
return get(userId).map(UserDevicesSnapshot::toPairs).orElseGet(List::of);
}
public void evict(String userId) {
remove(userId);
}
@Override
protected Optional<CacheLoader<String, UserDevicesSnapshot>> loader() {
return Optional.of(loader);
}
@Override
protected Optional<UserDevicesSnapshot> loadFromSource(String userId) {
return loader.load(userId);
}
}
The same loader serves both paths: the engine calls it on a miss (read-through), and the facade
calls it directly when there is no region. .localFallback(10_000) means a pod whose
engine is off still caches up to ten thousand users for the effective TTL instead of calling IDM on
every request.
Loading
Read-through — a loader on the facade
Override loader(). A miss loads exactly one row inside one pod; every other pod
finds it in L2. Use loadAll(keys) on the loader when a batched query is more efficient than
N single reads.
Cache-aside — getOrLoad at the call site
Leave loader() unset when the source call needs request context (a caller's token,
HTTP headers) that a loader bean would not have. The region is then CACHE_ASIDE and the
caller supplies the value:
return new ResponseEntity<>(
pasRelationshipCache.getOrLoad(LookupKeys.of(partnerId, rmId), () -> loadRelationships(partnerId, rmId)).orElse(null),
HttpStatus.OK);
Warming a working set
Fill the region with rows already selected from the database before traffic arrives — a time
window, or the rows a business event makes relevant. warm never writes them back:
public int warm(Map<String, TransactionSnapshot> rows) {
return region().map(r -> r.warm(rows)).orElse(0);
}
If "a client request must never call the source" is a hard requirement, configure the region cache-aside, warm every required key before the pod reports ready, and treat an unexpected miss as a controlled unavailable response. Do not call that setup read-through; the application owns the readiness check because it owns the warm-up query.
Keeping it fresh
After data changes, use the first option that fits the write path. TTL is the last line.
| # | what to do | use it when | result |
|---|---|---|---|
| 1 | Register CacheWriteBinding.evict(region, Entity.class, Entity::getId) once | normal Hibernate/JPA entity writes | insert, update and delete evict the key automatically after commit; a rollback evicts nothing |
| 2 | Call CacheFreshness.evictAfterCommit(region, key) / refreshAfterCommit / refreshRegionAfterCommit 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 region and key | another service or batch job owns the write | one pod reloads that row; the others drop their L1 copy |
| 4 | facade.remove(key) / facade.put(key, value) | the owning service changed the row in code — e.g. userAlertsCache.evict(loginId) | runs after the surrounding commit; the invalidation bus clears every pod's L1 |
| 5 | Write through the cache with a writer() and WRITE_BEHIND | many rapid updates should be batched | cache changes now; the database flushes later, coalesced by key |
| 6 | Let the TTL expire | an earlier refresh was missed | the stale copy eventually disappears |
@Bean
CacheWriteBinding<Transaction> transactionWrites() {
return CacheWriteBinding.evict("transactions", Transaction.class, Transaction::getId);
}
Why evict rather than refresh? Eviction is the safe default: every pod drops the old value and the next read loads the committed row. CacheWriteBinding.refresh(…) reloads a hot key immediately through the refresh channel; if publishing that refresh fails, the binding falls back to eviction.
Transaction guards
Reading a cache from inside a database transaction, and writing to it from inside one, both have
traps. The facade closes them through CacheSupport:
| trap | guard |
|---|---|
| A source fallback opens lazy relations or a new connection in the middle of a write transaction, or runs with no transaction at all. | readFromSource runs the fallback in a read-only TransactionTemplate that joins an active transaction and opens a short read-only one otherwise. |
A put is visible to every pod before the database commit — then the transaction rolls back and the cache holds a value that never existed. | put, remove and refreshKey run through afterCommit: with an active transaction they are queued on a TransactionSynchronization and execute only after a successful commit; without one they run at once. |
| A static region is patched with a single row and no longer matches its version. | put on a static binding is ignored with a warning. |
The loader side has the same discipline: UserAlertsCache hands its loader the
read-only TransactionTemplate so from(entity) can walk the aggregate:
@Override
protected Optional<CacheLoader<String, UserAlertsByLogin>> loader() {
return Optional.of(new UserAlertsCacheLoader(repository, readOnlyTx.getObject()));
}
@Override
protected CacheRegionConfig.Builder<String, UserAlertsByLogin> customise(
CacheRegionConfig.Builder<String, UserAlertsByLogin> builder) {
return builder.namespace(UserAlertsCacheContract.NAMESPACE); // shared with alert-be-app
}
Choosing a key
- Good: the row's primary key or a stable business id (
TX-1001, a jbUid, a login id). - Composite lookups: join the parts with a stable separator in one place —
LookupKeys.of(partnerId, rmId)→"P1|RM7"— and declare a secondary index if the lookup is by a business field. - Avoid: names, statuses, anything that may change.
- Never client data: keys appear in Redis, logs and refresh events. Use technical identifiers.
Caching "not found"
If callers repeatedly ask for a key that does not exist, remember that result briefly so the
source is asked once: negative-ttl on the region (per environment) or
.negativeTtl(…) in customise. The engine stores a sentinel, never a
null; the facade returns Optional.empty() as usual.
Write policies
| policy | meaning | facade |
|---|---|---|
NONE | read-only from this application's point of view | the default when writer() is not overridden |
WRITE_THROUGH | update the database now, then the cache | writer() + customise(b → b.write(WRITE_THROUGH)) |
| write-around | update the database and evict; the next read reloads | not a policy value — it is NONE plus the Hibernate write binding or remove(key), and the recommended default |
WRITE_BEHIND | update the cache now, batch database writes in the background | writer() + .write(WRITE_BEHIND).writeBehind(WriteBehindOptions…); only when rapid updates make immediate writes too expensive |
Do / don't
| ✓ 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 |
| Keep TTLs modest — seconds to minutes for data that changes | Raise the TTL to improve the hit ratio of changing data |
| Declare the TTL on the binding and override it per environment | Hard-code a TTL in a custom property the framework does not know about |
Use negative-ttl when absent keys are requested often | Let every request for a missing row hit the database |
Give external-lookup facades a localFallback | Let an engine outage turn into a source outage |
Complete example
The full transactional walk-through — properties, entity, repository, loader and facade, service with warm-up, controller — is on the complete code examples page, and runs live as the transaction ledger in the live initializer.