Spring Boot + Redis Caching: Strategies and Best Practices for Production Systems
Introduction
Caching is one of the most powerful performance techniques available to backend engineers. A well-placed cache can turn a 200ms database call into a sub-millisecond memory lookup — the difference between an API that strains under load and one that handles thousands of requests per second with ease.
Redis has become the de-facto caching layer for Java applications. It's fast (sub-millisecond latency), supports rich data structures, and integrates seamlessly with Spring Boot through the Spring Cache Abstraction. Best of all, it's swappable: you write your caching logic once against Spring's annotations, and changing from an in-memory ConcurrentHashMap to a distributed Redis cluster is a configuration change, not a refactor.
In this tutorial, we'll build a complete product catalogue service with Redis caching. We'll cover:
- Setting up Spring Boot 3.3 with
spring-boot-starter-data-redis - The Spring Cache Abstraction:
@Cacheable,@CachePut,@CacheEvict - Custom TTLs, serialization, and multiple cache configurations
- Conditional caching and cache-aside patterns
- Testing caching logic with Testcontainers
By the end you'll have a production-ready template you can adapt to your own microservices.
Prerequisites
- Java 21+
- Maven 3.9+
- Docker Desktop (for running Redis locally via Testcontainers)
- Basic Spring Boot knowledge
If you want to run the app without Docker, install Redis locally (brew install redis on macOS, or use the official Windows installer) and start it with redis-server.
Project Setup
Create a new Spring Boot project. Your pom.xml should declare these dependencies:
<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-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-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
The key additions are spring-boot-starter-data-redis (Lettuce client + RedisTemplate) and spring-boot-starter-cache (the annotation-driven caching infrastructure).
Application Configuration
Configure your Redis connection and cache behaviour in application.yml:
spring:
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 4
min-idle: 1
max-wait: 1000ms
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes default TTL (milliseconds)
cache-null-values: false
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
logging:
level:
org.springframework.cache: DEBUG
Setting spring.cache.type: redis is what wires Spring's CacheManager to Redis. Without it, Boot falls back to a ConcurrentHashMap-backed SimpleCacheManager.
The logging.level.org.springframework.cache: DEBUG line is invaluable during development — you'll see every cache hit, miss, put, and evict in your console.
The Domain Model
package com.vimleshpandey.demo.model;
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;
@Column(nullable = false)
private String category;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private String description;
private boolean active = true;
// Constructors
public Product() {}
public Product(String name, String category, BigDecimal price, String description) {
this.name = name;
this.category = category;
this.price = price;
this.description = description;
}
// Getters and setters (abbreviated)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}
Product implements Serializable — this is required when Redis uses the default JDK serializer. We'll override this with Jackson in the cache configuration below, but having Serializable as a fallback is good practice.
Custom Cache Configuration
Spring Boot's auto-configured RedisCacheManager applies a single TTL to every cache. For real applications you want finer control — products might be cached for 10 minutes while user sessions expire after 30 minutes and price data only lasts 2 minutes.
package com.vimleshpandey.demo.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.Map;
@Configuration
@EnableCaching
public class CacheConfig {
private RedisCacheConfiguration defaultCacheConfig() {
ObjectMapper om = new ObjectMapper();
om.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(om);
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
.disableCachingNullValues();
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"products", defaultCacheConfig().entryTtl(Duration.ofMinutes(10)),
"productsByCategory", defaultCacheConfig().entryTtl(Duration.ofMinutes(5)),
"productPrices", defaultCacheConfig().entryTtl(Duration.ofMinutes(2))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultCacheConfig())
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
Key decisions here:
- Jackson serializer over JDK serializer: JSON is human-readable (you can inspect cached values with
redis-cli), smaller in size, and version-tolerant (adding a new field won't break old cached data).
activateDefaultTyping: This embeds the Java class name in the JSON so Spring knows how to deserialize it back. Without it, Spring can't reconstruct yourProductobject from raw JSON.
- Per-cache TTLs: Price data expires faster (2 min) because it's more volatile; broad product listings expire in 5 minutes; individual product records last 10 minutes.
Service Layer with Caching Annotations
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
log.debug("Cache MISS for product id={}", id);
return repo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@Cacheable(value = "productsByCategory", key = "#category")
public List<Product> findByCategory(String category) {
log.debug("Cache MISS for category={}", category);
return repo.findByCategoryAndActiveTrue(category);
}
@Transactional
@CachePut(value = "products", key = "#result.id")
@CacheEvict(value = "productsByCategory", key = "#product.category")
public Product save(Product product) {
Product saved = repo.save(product);
log.debug("Saved product id={}, cache updated", saved.getId());
return saved;
}
@Transactional
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "productsByCategory", allEntries = true)
})
public void deleteById(Long id) {
repo.deleteById(id);
log.debug("Deleted product id={}, cache evicted", id);
}
@Transactional
@Caching(
put = { @CachePut(value = "products", key = "#result.id") },
evict = { @CacheEvict(value = "productsByCategory", allEntries = true) }
)
public Product update(Long id, Product updated) {
Product existing = repo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
existing.setName(updated.getName());
existing.setPrice(updated.getPrice());
existing.setDescription(updated.getDescription());
return repo.save(existing);
}
// Conditional caching — only cache results with more than 0 products
@Cacheable(value = "productsByCategory", key = "#category",
condition = "#category != null", unless = "#result.isEmpty()")
public List<Product> findByCategoryConditional(String category) {
return repo.findByCategoryAndActiveTrue(category);
}
}
Understanding the Three Core Annotations
@Cacheable — "Check the cache first; call the method only on a miss."
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) { ... }
The first call for id=42 executes the method and stores the result under key products::42. Every subsequent call returns the cached value without touching the database.
@CachePut — "Always call the method, then update the cache with the result."
Use this for create/update operations where you want the cache to reflect the latest state without requiring the next read to hit the database first.
@CacheEvict — "Remove entries from the cache."
Use allEntries = true when a change to one entry could invalidate an entire collection (e.g., adding a product invalidates the productsByCategory list cache for that category).
@Caching — Combines multiple cache operations in one annotation when @CachePut + @CacheEvict would conflict on the same method.
The Repository
package com.vimleshpandey.demo.repository;
import com.vimleshpandey.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategoryAndActiveTrue(String category);
}
REST Controller
package com.vimleshpandey.demo.controller;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import org.springframework.http.HttpStatus;
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<Product> getById(@PathVariable Long id) {
return ResponseEntity.ok(productService.findById(id));
}
@GetMapping("/category/{category}")
public ResponseEntity<List<Product>> getByCategory(@PathVariable String category) {
return ResponseEntity.ok(productService.findByCategory(category));
}
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
return ResponseEntity.status(HttpStatus.CREATED).body(productService.save(product));
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody Product product) {
return ResponseEntity.ok(productService.update(id, product));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Testing with Testcontainers
Testing cached behaviour without a real Redis instance means using mock-heavy tests that can miss serialization errors or TTL misconfigurations. Testcontainers gives you a real Redis running in Docker during your test suite.
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.model.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.test.mock.mockito.SpyBean;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@Testcontainers
@SpringBootTest
class ProductServiceCacheTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
private ProductService productService;
@SpyBean
private ProductRepository productRepository;
private Product savedProduct;
@BeforeEach
void setup() {
System.setProperty("spring.data.redis.host", redis.getHost());
System.setProperty("spring.data.redis.port", redis.getMappedPort(6379).toString());
savedProduct = productRepository.save(
new Product("Laptop Pro", "Electronics", new BigDecimal("1299.99"), "Powerful laptop")
);
}
@Test
void findById_secondCallHitsCacheNotRepository() {
// First call — cache miss, hits repository
Product first = productService.findById(savedProduct.getId());
// Second call — should hit cache
Product second = productService.findById(savedProduct.getId());
assertThat(first.getName()).isEqualTo(second.getName());
// Repository should have been queried exactly once
verify(productRepository, times(1)).findById(savedProduct.getId());
}
@Test
void delete_evictsProductFromCache() {
productService.findById(savedProduct.getId()); // populate cache
productService.deleteById(savedProduct.getId()); // should evict
// After eviction, repository is queried again on next access
try {
productService.findById(savedProduct.getId());
} catch (Exception e) {
// Product deleted, expected
}
verify(productRepository, times(2)).findById(savedProduct.getId());
}
@Test
void update_updatesCacheWithNewValue() {
productService.findById(savedProduct.getId()); // populate cache
Product updated = new Product("Laptop Pro Max", "Electronics",
new BigDecimal("1499.99"), "Upgraded laptop");
productService.update(savedProduct.getId(), updated);
Product fromCache = productService.findById(savedProduct.getId());
assertThat(fromCache.getName()).isEqualTo("Laptop Pro Max");
// Only one DB query after update (the update itself + one initial read = 2 total)
verify(productRepository, atMost(2)).findById(savedProduct.getId());
}
}
Running the Application
- Start Redis locally:
docker run -d -p 6379:6379 redis:7-alpine - Run the Spring Boot app:
./mvnw spring-boot:run - Test the caching behaviour:
# Create a product
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop Pro","category":"Electronics","price":1299.99,"description":"Powerful laptop"}'
# First GET — watch the DEBUG log show a cache miss
curl http://localhost:8080/api/products/1
# Second GET — the log should show nothing (cache hit, method not called)
curl http://localhost:8080/api/products/1
# Inspect the cache directly in Redis
docker exec -it <container_id> redis-cli
> KEYS *
> TTL products::1
> GET products::1
You'll see the JSON payload stored in Redis with the Jackson type annotation embedded.
Advanced Strategy: Cache-Aside with Programmatic Control
Sometimes annotation-based caching isn't flexible enough. The CacheManager API lets you interact with caches programmatically:
package com.vimleshpandey.demo.service;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class PriceCacheService {
private final CacheManager cacheManager;
public PriceCacheService(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
public void warmUp(Long productId, java.math.BigDecimal price) {
Cache cache = cacheManager.getCache("productPrices");
if (cache != null) {
cache.put(productId, price);
}
}
public Optional<java.math.BigDecimal> getPrice(Long productId) {
Cache cache = cacheManager.getCache("productPrices");
if (cache == null) return Optional.empty();
Cache.ValueWrapper wrapper = cache.get(productId);
return Optional.ofNullable(wrapper != null ? (java.math.BigDecimal) wrapper.get() : null);
}
public void invalidateAll() {
Cache cache = cacheManager.getCache("productPrices");
if (cache != null) cache.clear();
}
}
This is useful for cache warm-up at application startup (pre-populating hot keys from the DB before the first real traffic arrives) and for bulk invalidation operations like a flash-sale price reset.
Common Pitfalls and Tips
1. Self-invocation breaks caching
Spring's caching works through AOP proxies. If a cached method calls another cached method on the same bean, the proxy is bypassed:// WRONG — internal call skips the cache proxy
public void process(Long id) {
Product p = findById(id); // cache miss every time!
}
// CORRECT — inject the service bean to go through the proxy
@Autowired
private ProductService self;
public void process(Long id) {
Product p = self.findById(id); // cache works correctly
}
2. Serialization failures are silent
If your cached object gains a non-serializable field,@Cacheable silently falls through to the method instead of throwing. Always test serialization explicitly.
3. Cache stampede on cold start
When a popular key expires, many concurrent requests can simultaneously miss the cache and flood the database. Mitigate with:- Probabilistic early expiration (refresh slightly before TTL)
- Request coalescing (only let one request refresh; others wait)
- Cache warm-up at startup (pre-load frequent keys)
4. allEntries = true is expensive
@CacheEvict(allEntries = true) flushes the entire named cache. On a high-traffic system with thousands of cached keys, do this sparingly. Prefer keyed eviction where possible.
5. Use a key prefix strategy in production
In a microservices environment, multiple apps may share the same Redis instance. Prefix your cache names with the service name to avoid collisions:spring.cache.redis.key-prefix: product-service::
6. Monitor cache hit rates
Connect Redis to your observability stack. Spring Boot Actuator exposes cache metrics at/actuator/metrics/cache.gets:
management:
endpoints:
web:
exposure:
include: health, metrics, caches
Aim for a hit rate above 80% on frequently-read data. A low hit rate means your TTL is too short, your keys are too granular, or the access pattern is less repetitive than expected.
Conclusion
Redis caching with Spring Boot's annotation-driven abstraction is one of the highest return-on-investment optimisations you can apply to a data-heavy service. The setup is minimal, the annotations are expressive, and the payoff — dramatic reductions in database load and response latency — is immediate.
The patterns covered here — per-cache TTLs, Jackson serialization, @Caching composites, conditional caching, and programmatic cache warm-up — form a solid foundation for production use. The real work is operational: monitor your hit rates, tune your TTLs based on data volatility, and keep an eye on memory consumption as your dataset grows.
The complete project source code is available as a download with this article. Start with the ProductService patterns and the CacheConfig bean, and adapt them to your domain.