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-redisand 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-redispulls in Lettuce (the default non-blocking Redis client)spring-boot-starter-cacheactivates Spring's annotation-driven caching abstractiontestcontainers-redisfrom 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()— preventsnullfrom 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 withredis-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:
| Annotation | Executes method? | Updates cache? | Evicts cache? |
|---|---|---|---|
@Cacheable | Only on miss | On miss only | No |
@CachePut | Always | Always | No |
@CacheEvict | Always | No | Yes |
@Caching | Groups 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.