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:

  1. 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).
  1. activateDefaultTyping: This embeds the Java class name in the JSON so Spring knows how to deserialize it back. Without it, Spring can't reconstruct your Product object from raw JSON.
  1. 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

  1. Start Redis locally: docker run -d -p 6379:6379 redis:7-alpine
  2. Run the Spring Boot app: ./mvnw spring-boot:run
  3. 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.