Spring Boot + Redis: Advanced Caching Strategies and Implementation

Introduction

Every high-traffic web application eventually hits the same wall: the database becomes the bottleneck. You can scale vertically, add read replicas, and tune queries, but there is a ceiling. Redis — the blazing-fast in-memory data structure store — lets you break through that ceiling by serving the most frequently accessed data in microseconds rather than milliseconds.

In this tutorial you will build a production-ready product catalogue API that demonstrates the full spectrum of Spring Boot caching strategies with Redis:

  • @Cacheable, @CachePut, @CacheEvict, @Caching — the Spring annotation-driven caching model
  • Per-cache TTL configuration — products expire in 5 minutes, categories in 1 hour, featured items in 60 seconds
  • Custom cache key generation — controlling exactly what Redis key is used
  • RedisCacheManager — the low-level configuration layer
  • Cache warming on startup — pre-loading hot data so your first real request is never slow
  • Testing with Testcontainers — a real Redis 7.2 instance in your CI pipeline, no mocks

Version note: Spring Boot 3.5 reached open-source EOL in June 2026. Spring Boot 4.0 is the current actively supported line. All code in this tutorial compiles unchanged under Spring Boot 4.0 — the spring-boot-starter-data-redis and cache auto-configuration APIs are stable across both versions.

Prerequisites

  • Java 21 (LTS)
  • Maven 3.9+
  • Docker (for local Redis and Testcontainers tests)
  • MySQL 8.0+ (or H2 for quick experiments)
  • Basic familiarity with Spring Boot and JPA

Project Setup

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.3</version>
    <relativePath/>
  </parent>

  <groupId>com.vimleshpandey.demo</groupId>
  <artifactId>redis-cache-demo</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>redis-cache-demo</name>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
    </dependency>
    <!-- Redis + Cache -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- Test -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-testcontainers</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>com.redis</groupId>
      <artifactId>testcontainers-redis</artifactId>
      <version>2.2.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

Key dependencies:

  • spring-boot-starter-data-redis pulls in Lettuce (the default non-blocking Redis client)

  • spring-boot-starter-cache activates Spring's annotation-driven caching abstraction

  • testcontainers-redis from Redis Ltd gives us a purpose-built Redis Testcontainer

Configuration

application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/product_db
    username: root
    password: ${DB_PASSWORD:root}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 2

app:
  cache:
    products:
      ttl-seconds: 300     # 5 minutes
    categories:
      ttl-seconds: 3600    # 1 hour
    featured:
      ttl-seconds: 60      # 1 minute — high traffic, short-lived

The lettuce.pool section enables connection pooling. In production, always pool your Redis connections — the default single-connection mode is sufficient for development but will create a bottleneck under load.

Domain Model

Product Entity

package com.vimleshpandey.demo.entity;

import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class Product implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private String description;

    @Column(nullable = false)
    private BigDecimal price;

    @Column(nullable = false)
    private Integer stock;

    private boolean featured;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;

    // getters and setters omitted for brevity
}

Why implements Serializable? Spring's default Redis serializer for cache values uses Java serialization (or Jackson JSON when GenericJackson2JsonRedisSerializer is configured). Either way, your cached objects must be serializable. We will use Jackson JSON serialization — more readable and language-agnostic.

ProductDto

package com.vimleshpandey.demo.dto;

import java.io.Serializable;
import java.math.BigDecimal;

public class ProductDto implements Serializable {
    private Long id;
    private String name;
    private String description;
    private BigDecimal price;
    private Integer stock;
    private boolean featured;
    private Long categoryId;
    private String categoryName;

    public static ProductDto fromEntity(com.vimleshpandey.demo.entity.Product p) {
        ProductDto dto = new ProductDto();
        dto.id          = p.getId();
        dto.name        = p.getName();
        dto.description = p.getDescription();
        dto.price       = p.getPrice();
        dto.stock       = p.getStock();
        dto.featured    = p.isFeatured();
        if (p.getCategory() != null) {
            dto.categoryId   = p.getCategory().getId();
            dto.categoryName = p.getCategory().getName();
        }
        return dto;
    }

    // getters and setters omitted for brevity
}

Always cache DTOs, never JPA entities directly. Caching a JPA entity with lazy-loaded relationships will trigger a LazyInitializationException the moment Spring tries to serialize the proxy — a painful gotcha that catches many developers off guard.

Custom Cache Configuration

RedisCacheConfig.java

package com.vimleshpandey.demo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Value("${app.cache.products.ttl-seconds:300}")
    private long productsTtl;

    @Value("${app.cache.categories.ttl-seconds:3600}")
    private long categoriesTtl;

    @Value("${app.cache.featured.ttl-seconds:60}")
    private long featuredTtl;

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(300))
                .disableCachingNullValues()
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer()));

        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("products",   defaultConfig.entryTtl(Duration.ofSeconds(productsTtl)));
        cacheConfigs.put("categories", defaultConfig.entryTtl(Duration.ofSeconds(categoriesTtl)));
        cacheConfigs.put("featured",   defaultConfig.entryTtl(Duration.ofSeconds(featuredTtl)));

        return RedisCacheManager.builder(factory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

This is the heart of the setup. Notice:

  • disableCachingNullValues() — prevents null from being stored in Redis. Without this, a cache miss for a non-existent product gets stored as null and every subsequent lookup serves that stale null until the TTL expires.

  • GenericJackson2JsonRedisSerializer — stores values as human-readable JSON so you can inspect cache contents with redis-cli.

  • Per-cache TTL overrides — different data has different staleness tolerances. Categories are stable; featured items change frequently.

The Service Layer — Caching Annotations in Depth

ProductService.java

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.dto.ProductDto;
import com.vimleshpandey.demo.entity.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * Cache a single product by ID.
     * Redis key: "products::1", "products::42", etc.
     * On a cache hit, the method body is skipped entirely.
     */
    @Cacheable(value = "products", key = "#id")
    public ProductDto findById(Long id) {
        return productRepository.findById(id)
                .map(ProductDto::fromEntity)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
    }

    /**
     * Cache product list per category.
     * Redis key: "products::category_7"
     */
    @Cacheable(value = "products", key = "'category_' + #categoryId")
    public List<ProductDto> findByCategory(Long categoryId) {
        return productRepository.findByCategoryId(categoryId)
                .stream()
                .map(ProductDto::fromEntity)
                .toList();
    }

    /**
     * Featured products — short TTL, high read frequency.
     * Redis key: "featured::all"
     */
    @Cacheable(value = "featured", key = "'all'")
    public List<ProductDto> findFeatured() {
        return productRepository.findFeaturedWithCategory()
                .stream()
                .map(ProductDto::fromEntity)
                .toList();
    }

    /**
     * @CachePut ALWAYS executes the method AND updates the cache.
     * Use it for writes where you want the cache kept current.
     * We also evict the stale category list and featured list.
     */
    @Transactional
    @Caching(
        put   = { @CachePut(value = "products", key = "#result.id") },
        evict = {
            @CacheEvict(value = "products", key = "'category_' + #result.categoryId"),
            @CacheEvict(value = "featured",  key = "'all'")
        }
    )
    public ProductDto update(Long id, ProductDto dto) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
        product.setName(dto.getName());
        product.setDescription(dto.getDescription());
        product.setPrice(dto.getPrice());
        product.setStock(dto.getStock());
        product.setFeatured(dto.isFeatured());
        return ProductDto.fromEntity(productRepository.save(product));
    }

    /**
     * Evict the specific product and featured list on delete.
     */
    @Transactional
    @Caching(evict = {
        @CacheEvict(value = "products", key = "#id"),
        @CacheEvict(value = "featured",  key = "'all'")
    })
    public void delete(Long id) {
        if (!productRepository.existsById(id)) {
            throw new EntityNotFoundException("Product not found: " + id);
        }
        productRepository.deleteById(id);
    }

    /**
     * Nuclear option — wipe the entire products cache after a bulk import.
     */
    @CacheEvict(value = "products", allEntries = true)
    public void evictAllProducts() {
        // annotation handles everything
    }
}

Understanding the four caching annotations:

AnnotationExecutes method?Updates cache?Evicts cache?
@CacheableOnly on missOn miss onlyNo
@CachePutAlwaysAlwaysNo
@CacheEvictAlwaysNoYes
@CachingGroups multiple annotations together

The @Caching annotation is your Swiss army knife when a single operation needs to touch multiple cache entries — as is the case with update(), where we want to refresh the product's own entry, but also blow away the category list that contained it.

Cache Warming on Startup

Cold-cache hits on your busiest data after a deployment can spike database load sharply. A simple ApplicationRunner solves this:

CacheWarmingService.java

package com.vimleshpandey.demo.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Service;

@Service
public class CacheWarmingService implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(CacheWarmingService.class);

    private final ProductService productService;

    public CacheWarmingService(ProductService productService) {
        this.productService = productService;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info("Warming featured-products cache...");
        int count = productService.findFeatured().size();
        log.info("Cache warm-up complete: {} featured products loaded.", count);
    }
}

This triggers findFeatured() during application start, which populates the featured::all cache entry. The next real request arrives to a warm cache. For very large datasets, consider warming only the top-N most-accessed items rather than the entire catalogue.

The REST Controller

ProductController.java

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.dto.ProductDto;
import com.vimleshpandey.demo.service.ProductService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDto> getById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.findById(id));
    }

    @GetMapping("/category/{categoryId}")
    public ResponseEntity<List<ProductDto>> getByCategory(@PathVariable Long categoryId) {
        return ResponseEntity.ok(productService.findByCategory(categoryId));
    }

    @GetMapping("/featured")
    public ResponseEntity<List<ProductDto>> getFeatured() {
        return ResponseEntity.ok(productService.findFeatured());
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductDto> update(
            @PathVariable Long id,
            @RequestBody ProductDto dto) {
        return ResponseEntity.ok(productService.update(id, dto));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/evict")
    public ResponseEntity<Void> evictAll() {
        productService.evictAllProducts();
        return ResponseEntity.ok().build();
    }
}

Testing with Testcontainers

The worst thing you can do is mock your Redis calls in tests — you lose confidence that serialization works, that TTLs are applied, and that key generation is correct. Testcontainers spins up a real Redis 7.2 container for each test run.

ProductServiceTest.java

package com.vimleshpandey.demo.service;

import com.redis.testcontainers.RedisContainer;
import com.vimleshpandey.demo.dto.ProductDto;
import com.vimleshpandey.demo.entity.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.cache.CacheManager;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class ProductServiceTest {

    @Container
    @ServiceConnection
    static RedisContainer redis =
            new RedisContainer(RedisContainer.DEFAULT_IMAGE_NAME.withTag("7.2"));

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

    private Product savedProduct;

    @BeforeEach
    void setUp() {
        productRepository.deleteAll();
        cacheManager.getCacheNames().forEach(name ->
                cacheManager.getCache(name).clear());

        Product p = new Product();
        p.setName("Wireless Mouse");
        p.setDescription("Ergonomic 3-button wireless mouse");
        p.setPrice(new BigDecimal("29.99"));
        p.setStock(150);
        p.setFeatured(false);
        savedProduct = productRepository.save(p);
    }

    @Test
    void findById_shouldPopulateCache() {
        ProductDto result = productService.findById(savedProduct.getId());
        assertThat(result.getName()).isEqualTo("Wireless Mouse");
        // Cache entry must exist after first call
        assertThat(cacheManager.getCache("products")
                .get(savedProduct.getId())).isNotNull();
    }

    @Test
    void update_shouldRefreshCacheEntry() {
        productService.findById(savedProduct.getId());     // warm cache

        ProductDto dto = new ProductDto();
        dto.setName("Wireless Mouse Pro");
        dto.setDescription("Updated model");
        dto.setPrice(new BigDecimal("39.99"));
        dto.setStock(100);
        dto.setFeatured(true);

        ProductDto updated = productService.update(savedProduct.getId(), dto);
        assertThat(updated.getName()).isEqualTo("Wireless Mouse Pro");
        // @CachePut ensures the new value is in cache
        assertThat(cacheManager.getCache("products")
                .get(savedProduct.getId())).isNotNull();
    }

    @Test
    void delete_shouldEvictCacheEntry() {
        productService.findById(savedProduct.getId());     // warm cache
        productService.delete(savedProduct.getId());

        assertThat(cacheManager.getCache("products")
                .get(savedProduct.getId())).isNull();
    }
}

The @ServiceConnection annotation (available since Spring Boot 3.1) wires the Testcontainer's dynamic Redis port directly into the spring.data.redis.* properties — no @DynamicPropertySource boilerplate required.

Running the Application

# Start Redis locally
docker run -d -p 6379:6379 --name redis redis:7.2-alpine

# Start MySQL and create the database
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS product_db;"

# Run the app
mvn spring-boot:run

# Test the endpoints
curl http://localhost:8080/api/products/featured
curl http://localhost:8080/api/products/1

# Inspect what's in Redis
docker exec -it redis redis-cli KEYS "products::*"
docker exec -it redis redis-cli GET "products::1"

Common Pitfalls and Tips

1. Never cache JPA entities — cache DTOs

As mentioned earlier, lazy-loaded proxy fields on a JPA entity will explode at serialization time. Always convert to a plain Serializable DTO before caching.

2. Use @Transactional(readOnly = true) on the service class

Pair it with @Transactional on write methods. This is good practice regardless of caching — it signals intent to Hibernate and lets the JDBC driver optimize reads.

3. Beware of cache stampede

When a popular cache entry expires, many concurrent requests may hit the database simultaneously before anyone can repopulate the cache. For read-heavy workloads, consider a probabilistic early-expiry strategy (PER): refresh the cache entry slightly before it expires with a small random probability on each read.

4. Test cache eviction as part of your integration tests

The most common caching bug is a missed @CacheEvict after a write. Always write a test that (1) reads data to populate the cache, (2) mutates it, then (3) verifies the cache entry is gone or updated.

5. Name your caches explicitly and keep them narrow

Prefer @CacheEvict(value = "products", key = "#id") over @CacheEvict(value = "products", allEntries = true). Evicting all entries is a blunt instrument — it solves cache consistency at the cost of a thundering herd on the next read.

6. Monitor cache hit ratios in production

Redis exposes INFO stats with keyspace_hits and keyspace_misses. A hit ratio below 80% often means your TTLs are too short or your key space is too fragmented. Expose these via an /actuator/metrics custom metric or a Grafana + Redis Exporter dashboard.

Conclusion

Redis caching with Spring Boot is one of the highest-leverage performance improvements you can make to a read-heavy application. The annotation-driven model (@Cacheable, @CachePut, @CacheEvict) keeps your service layer clean; the RedisCacheManager gives you surgical control over serialization and per-cache TTLs; and Testcontainers ensures your tests run against a real Redis instance so you do not ship surprises to production.

The patterns here — separate TTLs per data type, DTO caching, cache warming on startup, Testcontainers integration testing — translate directly to any Spring Boot application, whether you are caching user sessions, API responses, or expensive computation results. Start small, measure your hit ratios, and iterate.

The complete source code for this tutorial is available as a downloadable ZIP above.