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).

On this page

    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:

    1. A TTL on every entry — declared in code as the default, tunable per environment. It is the safety net, not the freshness mechanism.
    2. An eviction or refresh on every write path — the Hibernate write binding for JPA writes, CacheFreshness for bulk SQL, refresh events for writers in other services, put/remove on the facade.
    3. 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);
    }
    Note

    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 douse it whenresult
    1Register CacheWriteBinding.evict(region, Entity.class, Entity::getId) oncenormal Hibernate/JPA entity writesinsert, update and delete evict the key automatically after commit; a rollback evicts nothing
    2Call CacheFreshness.evictAfterCommit(region, key) / refreshAfterCommit / refreshRegionAfterCommit inside the transactionbulk JPQL or native SQL bypasses entity listenersthe action waits for commit and is dropped on rollback
    3Publish a refresh event with region and keyanother service or batch job owns the writeone pod reloads that row; the others drop their L1 copy
    4facade.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
    5Write through the cache with a writer() and WRITE_BEHINDmany rapid updates should be batchedcache changes now; the database flushes later, coalesced by key
    6Let the TTL expirean earlier refresh was missedthe stale copy eventually disappears
    @Bean
    CacheWriteBinding<Transaction> transactionWrites() {
        return CacheWriteBinding.evict("transactions", Transaction.class, Transaction::getId);
    }
    Tip

    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:

    trapguard
    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

    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

    policymeaningfacade
    NONEread-only from this application's point of viewthe default when writer() is not overridden
    WRITE_THROUGHupdate the database now, then the cachewriter() + customise(b → b.write(WRITE_THROUGH))
    write-aroundupdate the database and evict; the next read reloadsnot a policy value — it is NONE plus the Hibernate write binding or remove(key), and the recommended default
    WRITE_BEHINDupdate the cache now, batch database writes in the backgroundwriter() + .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 dataUpdate the database directly and hope the TTL fixes it
    Cache plain snapshot recordsCache JPA entities, or objects you keep changing after put
    Keep TTLs modest — seconds to minutes for data that changesRaise the TTL to improve the hit ratio of changing data
    Declare the TTL on the binding and override it per environmentHard-code a TTL in a custom property the framework does not know about
    Use negative-ttl when absent keys are requested oftenLet every request for a missing row hit the database
    Give external-lookup facades a localFallbackLet 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.