Complete code examples

Two end-to-end walk-throughs you can copy file by file: a static reference table and a transactional table, each from properties to controller, written against the facade API. Both run live in the live initializer.

On this page

    Static data — product categories

    Use this path for a small reference table such as countries, status codes or product categories. The library loads the complete table as one versioned snapshot and readers switch to a new version only after a later reload succeeds. Add these files in order; the facade keeps the repository fallback visible, so a cache that is off, still loading or temporarily unavailable never stops the application from reading its source.

    1. Properties

    spring:
      application:
        name: product-service
      data:
        redis:
          host: ${REDIS_HOST:localhost}
          port: ${REDIS_PORT:6379}
    
    dac:
      cache:
        enabled: true
        refresh-cron: "0 */15 * * * *"          # leader reloads every static region
        regions:
          productCategories:
            l1-max-size: 5000
            startup: reload-if-stale
            max-snapshot-age: 24h

    l1-max-size applies to the in-memory copy inside each pod. It is temporary: it needs no session affinity, disappears when the pod stops, and is rebuilt from Redis or the source.

    2. Entity

    The annotation on the entity is the cache configuration. The build generates the record stored in the cache and its BINDING; the master-data starter finds the repository, loads all rows, maps them with the generated from(…) and builds the byName index.

    @Entity
    @Table(name = "product_category")
    @CacheSnapshot(
            region = "productCategories",
            indexes = @SnapshotIndex(name = "byName", property = "name"))
    public class ProductCategory {
    
        @Id
        private Long id;
        private String name;
        private String description;
    
        protected ProductCategory() { }
    
        public Long getId() { return id; }
        public String getName() { return name; }
        public String getDescription() { return description; }
    }

    3. Repository

    public interface ProductCategoryRepository extends CrudRepository<ProductCategory, Long> {
        Optional<ProductCategory> findByNameIgnoreCase(String name);
    }

    4. Cached value (generated)

    Written by the build into target/generated-sources/annotations; you read it, you do not edit it.

    // Generated by cache-snapshot-processor from ProductCategory. Do not edit.
    @MasterDataRegion(
            name = "productCategories",
            entity = ProductCategory.class,
            indexes = @RegionIndex(name = "byName", property = "name"))
    public record ProductCategorySnapshot(Long id, String name, String description) {
    
        public static final String REGION = "productCategories";
    
        public static final RegionBinding<Long, ProductCategorySnapshot> BINDING =
                RegionBinding.staticRegion("productCategories", Long.class, ProductCategorySnapshot.class)
                        .index("byName", ProductCategorySnapshot::name)
                        .build();
    
        public static ProductCategorySnapshot from(ProductCategory entity) {
            if (entity == null) {
                return null;
            }
            return new ProductCategorySnapshot(entity.getId(), entity.getName(), entity.getDescription());
        }
    }

    5. Facade

    The typed entry point. get and byIndex answer from L1 or L2 while the region is loaded; the .or(…) branch is the deliberate safety path that runs only when it is not.

    @Component
    public class ProductCategoryCache extends AbstractCache<Long, ProductCategorySnapshot> {
    
        private final ProductCategoryRepository repository;
    
        public ProductCategoryCache(CacheSupport support, ProductCategoryRepository repository) {
            super(ProductCategorySnapshot.BINDING, support);
            this.repository = repository;
        }
    
        public Optional<ProductCategorySnapshot> byId(Long id) {
            return get(id).or(() -> repository.findById(id).map(ProductCategorySnapshot::from));
        }
    
        public Optional<ProductCategorySnapshot> byName(String name) {
            return byIndex("byName", name)
                    .or(() -> repository.findByNameIgnoreCase(name).map(ProductCategorySnapshot::from));
        }
    }

    6. Controller

    @RestController
    @RequestMapping("/product-categories")
    public class ProductCategoryController {
    
        private final ProductCategoryCache categories;
    
        public ProductCategoryController(ProductCategoryCache categories) {
            this.categories = categories;
        }
    
        @GetMapping("/{id}")
        public ResponseEntity<ProductCategorySnapshot> byId(@PathVariable Long id) {
            return categories.byId(id).map(ResponseEntity::ok)
                    .orElseGet(() -> ResponseEntity.notFound().build());
        }
    
        @GetMapping
        public ResponseEntity<ProductCategorySnapshot> byName(@RequestParam String name) {
            return categories.byName(name).map(ResponseEntity::ok)
                    .orElseGet(() -> ResponseEntity.notFound().build());
        }
    }
    Note

    When this table changes, publish a refresh event after the source commit, call categories.refresh(), or rely on the scheduled reload. If commit and event delivery must succeed together, publish through a transactional outbox.

    Transactional data — transactions

    Use this for orders, payments or transactions that keep changing. Do not load the whole table: warm a bounded working set, load an unexpected miss by id, and give every cached row a TTL. If a time window is not useful, a relevant application event can select and push a bounded set instead.

    1. Properties

    dac:
      cache:
        enabled: true
        regions:
          transactions:
            l1-max-size: 1000
            l1-ttl: ${TRANSACTION_L1_TTL:2m}
            l2-ttl: ${TRANSACTION_CACHE_TTL:5m}       # overrides the PT5M declared on the entity
            negative-ttl: ${TRANSACTION_NEGATIVE_TTL:30s}

    The entity declares the safe default; these values win at startup, so each environment can tune the TTL and size without rebuilding. TTL is the repair backstop — normal writes actively evict.

    2. Entity and cached value

    @Entity
    @Table(name = "transactions")
    @CacheSnapshot(region = "transactions", type = RegionKind.TRANSACTIONAL, ttl = "PT5M")
    public class Transaction {
    
        @Id
        private String id;
        private String clientId;
        private Instant bookedAt;
        private BigDecimal amount;
        private String currency;
        private String status;
    
        protected Transaction() { }
    
        public String getId() { return id; }
        public String getClientId() { return clientId; }
        public Instant getBookedAt() { return bookedAt; }
        public BigDecimal getAmount() { return amount; }
        public String getCurrency() { return currency; }
        public String getStatus() { return status; }
        public void setStatus(String status) { this.status = status; }
    }
    // Generated by cache-snapshot-processor from Transaction. Do not edit.
    public record TransactionSnapshot(String id, String clientId, Instant bookedAt,
                                      BigDecimal amount, String currency, String status) {
    
        public static final String REGION = "transactions";
    
        public static final RegionBinding<String, TransactionSnapshot> BINDING =
                RegionBinding.transactional("transactions", String.class, TransactionSnapshot.class)
                        .ttl(Duration.parse("PT5M"))
                        .build();
    
        public static TransactionSnapshot from(Transaction entity) { … }
    }

    3. Repository

    public interface TransactionRepository extends JpaRepository<Transaction, String> {
    
        List<Transaction> findByBookedAtGreaterThanEqual(Instant cutoff);
    
        List<Transaction> findByClientId(String clientId);
    
        @Modifying(clearAutomatically = true, flushAutomatically = true)
        @Query("update Transaction t set t.status = 'ARCHIVED' where t.bookedAt < :cutoff")
        int archiveBookedBefore(@Param("cutoff") Instant cutoff);
    }

    The first query defines the default time window; the second is data selected by a business event. Both are bounded by application rules. The bulk update shows the one path Hibernate entity listeners cannot observe.

    4. Facade and write binding

    @Component
    public class TransactionCache extends AbstractCache<String, TransactionSnapshot> {
    
        private final TransactionRepository repository;
    
        public TransactionCache(CacheSupport support, TransactionRepository repository) {
            super(TransactionSnapshot.BINDING, support);
            this.repository = repository;
        }
    
        @Override
        protected Optional<CacheLoader<String, TransactionSnapshot>> loader() {
            return Optional.of(id -> repository.findById(id).map(TransactionSnapshot::from));   // unexpected miss -> one DB read
        }
    
        @Override
        protected Optional<TransactionSnapshot> loadFromSource(String id) {
            return repository.findById(id).map(TransactionSnapshot::from);                      // engine off -> same read
        }
    
        public int warm(Map<String, TransactionSnapshot> rows) {
            return region().map(r -> r.warm(rows)).orElse(0);                                 // cache only; never writes back
        }
    }
    @Configuration(proxyBeanMethods = false)
    public class TransactionCacheConfig {
    
        @Bean
        CacheWriteBinding<Transaction> transactionWrites() {
            // Hibernate observes insert, update and delete. The eviction runs only after commit.
            return CacheWriteBinding.evict("transactions", Transaction.class, Transaction::getId);
        }
    }
    Tip

    Why evict? It is the safe default: every pod drops the old value and the next read loads the committed row. A failed cache action never rolls back the business transaction. Use CacheWriteBinding.refresh(…) only when a hot key should be reloaded immediately; if publishing that refresh fails, the binding falls back to eviction.

    5. Service

    @Service
    @Transactional(readOnly = true)
    public class TransactionService implements ApplicationRunner {
    
        private final TransactionRepository repository;
        private final TransactionCache cache;
        private final CacheFreshness cacheFreshness;
    
        public TransactionService(TransactionRepository repository, TransactionCache cache, CacheFreshness cacheFreshness) {
            this.repository = repository;
            this.cache = cache;
            this.cacheFreshness = cacheFreshness;
        }
    
        // Default load: warm a bounded time window during application startup.
        @Override
        public void run(ApplicationArguments args) {
            warmDefaultWindow(Duration.ofHours(24));
        }
    
        public int warmDefaultWindow(Duration window) {
            if (!cache.isEnabled()) {
                return 0;
            }
            return cache.warm(repository.findByBookedAtGreaterThanEqual(Instant.now().minus(window)).stream()
                    .map(TransactionSnapshot::from)
                    .collect(Collectors.toMap(TransactionSnapshot::id, Function.identity())));
        }
    
        // Alternative load: a relevant application event selects the rows when a time window is not useful.
        public int warmForEvent(String clientId) {
            if (!cache.isEnabled()) {
                return 0;
            }
            return cache.warm(repository.findByClientId(clientId).stream()
                    .map(TransactionSnapshot::from)
                    .collect(Collectors.toMap(TransactionSnapshot::id, Function.identity())));
        }
    
        // Miss handling: a loaded region calls the loader; an absent region uses loadFromSource.
        public Optional<TransactionSnapshot> findById(String id) {
            return cache.get(id);
        }
    
        @Transactional
        public TransactionSnapshot insert(Transaction transaction) {
            return TransactionSnapshot.from(repository.save(transaction));
        }
    
        @Transactional
        public Optional<TransactionSnapshot> updateStatus(String id, String status) {
            return repository.findById(id).map(row -> {
                row.setStatus(status);
                return TransactionSnapshot.from(row);
            });
        }
    
        @Transactional
        public boolean delete(String id) {
            return repository.findById(id).map(row -> {
                repository.delete(row);
                return true;
            }).orElse(false);
        }
    
        // Bulk JPQL bypasses entity listeners, so schedule freshness explicitly.
        @Transactional
        public int archiveOlderThan(Instant cutoff) {
            int changed = repository.archiveBookedBefore(cutoff);
            if (changed > 0) {
                cacheFreshness.refreshRegionAfterCommit(TransactionSnapshot.REGION);
            }
            return changed;
        }
    }

    Ordinary entity writes contain no cache code — the write binding evicts after commit. Only the bulk update calls CacheFreshness; that action also waits for commit and is discarded on rollback.

    6. Controller

    @RestController
    @RequestMapping("/transactions")
    public class TransactionController {
    
        private final TransactionService service;
    
        public TransactionController(TransactionService service) { this.service = service; }
    
        @GetMapping("/{id}")
        ResponseEntity<TransactionSnapshot> get(@PathVariable String id) {
            return service.findById(id).map(ResponseEntity::ok)
                    .orElseGet(() -> ResponseEntity.notFound().build());
        }
    
        @PostMapping("/cache-events/client/{clientId}")
        Map<String, Integer> onRelevantEvent(@PathVariable String clientId) {
            return Map.of("cachedRows", service.warmForEvent(clientId));
        }
    
        @PostMapping
        TransactionSnapshot insert(@RequestBody Transaction transaction) {
            return service.insert(transaction);
        }
    
        @PatchMapping("/{id}/status")
        ResponseEntity<TransactionSnapshot> update(@PathVariable String id, @RequestParam String value) {
            return service.updateStatus(id, value).map(ResponseEntity::ok)
                    .orElseGet(() -> ResponseEntity.notFound().build());
        }
    
        @DeleteMapping("/{id}")
        ResponseEntity<Void> delete(@PathVariable String id) {
            return service.delete(id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
        }
    }

    Insert, update and delete all pass through JPA, so the write binding evicts the key after a successful commit without cache calls in the controller or service. Run the default window, event load, miss, insert, update and delete paths yourself in the live initializer.