Getting started

Four steps take a Spring Boot service from no cache to a preloaded reference table and a cached transactional row. Each step ends with a check you can see in the log, and each one leaves the application working exactly as before if the cache is off.

On this page

    You need a Spring Boot service, a database repository, and a Redis or Valkey connection. For a local run this is enough:

    docker run -d -p 6379:6379 valkey/valkey:8.0

    Step 1 — Add the dependency

    <dependency>
        <groupId>com.juliusbaer.dac</groupId>
        <artifactId>cache-starter</artifactId>
        <version>${revision}</version>
    </dependency>

    Applications inside the client-notification-handler repository use ${revision} — the dac modules ride the same version train as the rest of the build. An application outside the repository pins the released version instead.

    The starter brings cache-core (the cache plane and the data plane), cache-binding-spring (Spring wiring, write bindings, @Stitch) and cache-masterdata-starter (preloading of static tables, refresh endpoints). Add one build-time dependency so the cache records are generated from your entities. It is provided and sits on the compiler's processor path, the same way Lombok does:

    <dependency>
        <groupId>com.juliusbaer.dac</groupId>
        <artifactId>cache-snapshot-processor</artifactId>
        <version>${revision}</version>
        <scope>provided</scope>
    </dependency>
    
    <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
            <annotationProcessorPaths>
                <path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></path>
                <path><groupId>com.juliusbaer.dac</groupId><artifactId>cache-snapshot-processor</artifactId><version>${revision}</version></path>
            </annotationProcessorPaths>
        </configuration>
    </plugin>
    ✓ Check: restart the application. It starts exactly as before — with no Redis configured the engine stays off and every read still goes to its source.

    Step 2 — Point at Redis

    spring:
      application:
        name: product-service      # becomes the Redis key root "product-service:cache"
      data:
        redis:
          host: localhost
          connect-timeout: 250ms     # fit these to your request SLA
          timeout: 500ms
    ✓ Check: restart and watch the log for Cache manager started successfully with 0 region(s). The engine is running; nothing is cached yet. If Redis is unreachable at startup the application still starts and reads from its source. The timeouts also cap how long an L1 miss waits if Redis fails later.

    Step 3 — Cache static data

    Pick a small reference table that changes rarely. One annotation on the JPA entity is the whole cache declaration. The build generates ProductCategorySnapshot — the record that is stored in the cache — together with a BINDING constant that describes the region:

    More information

    This step is the minimal path. How versioned snapshots work, the startup modes (attach / reload-if-stale), scheduled refresh and the index rules are all on Static data.

    @Entity
    @CacheSnapshot(region = "productCategories",
            indexes = @SnapshotIndex(name = "byName", property = "name"))
    public class ProductCategory {
        @Id private Long id;
        private String name;
        private String description;
        // getters
    }

    Then give the region a typed entry point — one small class, no region bean. Because the entity is JPA, the master-data starter finds its repository, preloads the whole table at startup and keeps it versioned; the facade only reads:

    @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> byName(String name) {
            return byIndex("byName", name)
                    .or(() -> repository.findByNameIgnoreCase(name).map(ProductCategorySnapshot::from));
        }
    }

    Read it anywhere:

    productCategoryCache.byName("Bonds");
    ✓ Check: restart and watch the log: Region 'productCategories' loaded snapshot v… (3 entries). Call the read twice — the database is not queried at all; both answers come from the cache.
    Note

    The .or(repository…) branch is the deliberate safety path. It runs only when the region is off or not loaded yet, so a service that is still warming up, or a deployment without Redis, keeps answering from the database. Static data explains when the fallback is reached.

    Step 4 — Cache transactional data

    Do not copy a large transaction table into the cache. Mark the entity transactional, give the region a TTL, and tell the facade how to load one missing row. The TTL is declared in code as the safe default and can be overridden per environment.

    More information

    TTL and negative caching, eviction after commit through the Hibernate write binding, refresh events, the local fallback and caching an external application's data are all on Transactional data.

    @Entity
    @CacheSnapshot(region = "transactions", type = RegionKind.TRANSACTIONAL, ttl = "PT5M")
    public class Transaction {
        @Id private String id;
        private String clientId;
        private BigDecimal amount;
        private String status;
        // getters and setters
    }
    @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));
        }
    
        @Override
        protected Optional<TransactionSnapshot> loadFromSource(String id) {
            return repository.findById(id).map(TransactionSnapshot::from);
        }
    }

    Keep the cache fresh after writes with one Hibernate write binding. Ordinary JPA inserts, updates and deletes then evict the key after a successful commit; a rollback leaves the cached value untouched. Nothing else in the service needs a cache call:

    @Bean
    CacheWriteBinding<Transaction> transactionWrites() {
        return CacheWriteBinding.evict("transactions", Transaction.class, Transaction::getId);
    }
    dac:
      cache:
        regions:
          transactions:
            l2-ttl: ${TRANSACTION_CACHE_TTL:30s}
    ✓ Check: read one row twice. The first read logs Cache miss in region 'transactions' reached the backing loader; the second is answered from memory in well under a millisecond. Wait for the TTL and read again — one fresh database load.

    Where to go next

    ConceptsThe read path, static vs transactional, what a snapshot is, and what happens when Redis is down. Data plane — the facadeEvery method and hook on AbstractCache, and how RegionBinding describes a region. Static dataStep 3 in depth: versioned snapshots, startup modes, scheduled refresh, indexes. Transactional dataStep 4 in depth: TTLs, eviction after commit, refresh events, external lookups. Live initializerRun every behaviour above on the demo with a live log.