View source on GitHub

Module 49 - Caching

What this module covers

Spring Cache abstraction with Redis as the production cache store. Key patterns: cache-aside with @Cacheable, write-through with @CachePut, eviction with @CacheEvict, and per-cache TTL configuration via RedisCacheManagerBuilderCustomizer.


Project structure

src/main/java/com/javatraining/caching/
├── CachingApplication.java
├── config/
│   └── CacheConfig.java          # @EnableCaching + per-cache TTL customizer
└── product/
    ├── Product.java               # @Entity
    ├── ProductRepository.java     # JpaRepository
    └── ProductService.java        # @Cacheable, @CachePut, @CacheEvict, @Caching

Spring Cache annotations

@Cacheable - cache-aside read

The method body runs only on a cache miss. On a hit, Spring returns the cached value without calling the method.

@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
    return productRepository.findById(id).orElse(null);
}

@Cacheable("productList")
public List<Product> findAll() {
    return productRepository.findAll();
}

@CachePut - write-through on save

The method always executes, and the result is written to the cache. A subsequent @Cacheable call for the same key skips the database entirely.

@CachePut(value = "products", key = "#result.id")

#result is a SpEL expression referring to the method’s return value.

@CacheEvict - invalidation on write/delete

@CacheEvict(value = "products", key = "#id")

allEntries = true removes every entry in the named cache (used for list caches that are wholesale stale after any mutation).

@Caching - combine multiple cache operations on one method

@Caching(
    put   = @CachePut(value = "products", key = "#result.id"),
    evict = @CacheEvict(value = "productList", allEntries = true)
)
public Product save(Product product) {
    return productRepository.save(product);
}

@Caching(evict = {
    @CacheEvict(value = "products", key = "#id"),
    @CacheEvict(value = "productList", allEntries = true)
})
public void deleteById(Long id) {
    productRepository.deleteById(id);
}

Redis configuration

RedisCacheManagerBuilderCustomizer customizes per-cache TTL and serializer when Spring Boot autoconfigures RedisCacheManager. The customizer is a plain bean - it does not open a Redis connection on creation.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        RedisSerializationContext.SerializationPair<Object> jsonPair =
                RedisSerializationContext.SerializationPair.fromSerializer(serializer);

        RedisCacheConfiguration products = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .disableCachingNullValues()
                .serializeValuesWith(jsonPair);

        RedisCacheConfiguration productList = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(1))
                .disableCachingNullValues()
                .serializeValuesWith(jsonPair);

        return builder -> builder
                .withCacheConfiguration("products", products)
                .withCacheConfiguration("productList", productList);
    }
}

GenericJackson2JsonRedisSerializer stores values as JSON with embedded type information, so no Serializable marker is needed on cached entities.

Production properties

spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

Testing without a Redis broker

spring.cache.type=simple selects ConcurrentMapCacheManager, which honours all the same @Cacheable/@CachePut/@CacheEvict semantics as RedisCacheManager. Redis autoconfiguration is excluded so no Lettuce connection is attempted.

# test/application.properties
spring.autoconfigure.exclude=\
  org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\
  org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
spring.cache.type=simple

Because RedisCacheManagerBuilderCustomizer is just a lambda bean with no Redis dependency, it is created safely and ignored during test context startup.

Test strategy

@SpyBean ProductRepository wraps the real JPA repository. verify() counts how many times the underlying database was actually hit, proving cache hits and misses without inspecting Redis internals.

@SpringBootTest
class ProductServiceCacheTest {

    @Autowired ProductService productService;
    @SpyBean  ProductRepository productRepository;
    @Autowired CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        cacheManager.getCache("productList").clear();
        saved = productRepository.save(new Product(null, "Widget", new BigDecimal("9.99")));
        clearInvocations(productRepository);
    }

    @Test
    void findById_served_from_cache_on_second_call() {
        productService.findById(saved.getId());
        productService.findById(saved.getId());
        verify(productRepository, times(1)).findById(saved.getId());
    }

    @Test
    void deleteById_evicts_cache_so_next_find_hits_repository() {
        productService.findById(saved.getId());
        productService.deleteById(saved.getId());
        clearInvocations(productRepository);
        productService.findById(saved.getId());
        verify(productRepository, times(1)).findById(saved.getId());
    }

    @Test
    void save_populates_cache_so_subsequent_find_skips_repository() {
        productService.save(new Product(saved.getId(), "Gadget", new BigDecimal("19.99")));
        clearInvocations(productRepository);
        productService.findById(saved.getId());
        verify(productRepository, never()).findById(saved.getId());
    }

    @Test
    void save_evicts_list_cache_so_next_findAll_hits_repository() {
        productService.findAll();
        productService.save(new Product(null, "New Item", new BigDecimal("5.00")));
        productService.findAll();
        verify(productRepository, times(2)).findAll();
    }
}

Tests

Class Type Count
ProductServiceCacheTest @SpringBootTest 4

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 4/4 pass


Key decisions

Decision Reason
RedisCacheManagerBuilderCustomizer instead of a direct RedisCacheManager bean Customizer plugs into Spring Boot’s autoconfiguration without breaking test context; no @ConditionalOnBean ordering issues
spring.cache.type=simple in tests Switches autoconfiguration to ConcurrentMapCacheManager with identical annotation semantics - no Docker, no embedded server
@CachePut with #result.id key Result-based key means the ID is set by the database (auto-increment) and known only after the insert
@Caching on save and deleteById Single annotation site that keeps both the per-item cache and the list cache in sync
disableCachingNullValues() Prevents a deleted entity from occupying cache space and masking a later re-insert