Spring Data Redis
Use when implementing caching, session storage, rate limiting, or any Redis integration. Covers cache-aside pattern, key naming, TTL strategy, and serialization config.
View SKILL.mdDependencies
<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>
Configuration
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jsonSerializer()); // JSON, not Java serialization
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jsonSerializer());
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware() // put/evict only after the surrounding transaction commits
.withCacheConfiguration("orders", config.entryTtl(Duration.ofMinutes(5)))
.withCacheConfiguration("products", config.entryTtl(Duration.ofHours(1)))
.build();
}
// Records are final, so DefaultTyping.NON_FINAL writes no type id for them and every
// cache read fails ("missing type id property '@class'"). The builder's defaultTyping(true)
// types user classes too; the validator limits type ids to an allow-list instead of
// LaissezFaire's "any class". BigDecimal is non-final, so java.math must be allowed.
private GenericJackson2JsonRedisSerializer jsonSerializer() {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.polymorphicTypeValidator(BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.") // your DTO packages
.allowIfSubType("java.util.")
.allowIfSubType("java.math.")
.allowIfSubType("java.time.")
.build())
.build();
return GenericJackson2JsonRedisSerializer.builder()
.objectMapper(mapper)
.defaultTyping(true)
.build();
}
}
Key Naming Convention
{app}:{domain}:{id} → orders:order:uuid-here
{app}:{domain}:list:{filter} → orders:order:list:status:PENDING
{app}:session:{userId} → orders:session:uuid-here
{app}:ratelimit:{ip} → orders:ratelimit:192.168.1.1
@Cacheable — Declarative Caching
@Service
@RequiredArgsConstructor
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductResponse findById(UUID id) {
return productRepository.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
}
@CachePut(value = "products", key = "#result.id") // update cache after write
@Transactional
public ProductResponse update(UUID id, UpdateProductRequest request) {
Product product = productRepository.findById(id).orElseThrow();
product.update(request);
return ProductResponse.from(productRepository.save(product));
}
@CacheEvict(value = "products", key = "#id") // invalidate on delete
@Transactional
public void delete(UUID id) {
productRepository.deleteById(id);
}
@CacheEvict(value = "products", allEntries = true) // clear all
public void clearCache() {}
}
Manual Cache-Aside Pattern
@Service
@RequiredArgsConstructor
public class OrderCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private static final Duration TTL = Duration.ofMinutes(5);
public Optional<OrderResponse> get(UUID orderId) {
String key = "orders:order:" + orderId;
Object cached = redisTemplate.opsForValue().get(key);
if (cached == null) return Optional.empty();
return Optional.of(objectMapper.convertValue(cached, OrderResponse.class));
}
public void put(OrderResponse order) {
String key = "orders:order:" + order.id();
redisTemplate.opsForValue().set(key, order, TTL);
}
public void evict(UUID orderId) {
redisTemplate.delete("orders:order:" + orderId);
}
}
Rate Limiting with Redis
@Component
@RequiredArgsConstructor
public class RateLimiter {
private final RedisTemplate<String, String> redisTemplate;
// INCR and PEXPIRE in ONE atomic script: as two separate commands, a crash or
// timeout between them leaves a key with no TTL and locks the caller out forever.
private static final RedisScript<Long> INCR_WITH_TTL = RedisScript.of(
"local c = redis.call('INCR', KEYS[1]) " +
"if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end " +
"return c", Long.class);
public boolean isAllowed(String identifier, int maxRequests, Duration window) {
String key = "ratelimit:" + identifier;
Long count = redisTemplate.execute(INCR_WITH_TTL, List.of(key), String.valueOf(window.toMillis()));
return count != null && count <= maxRequests;
}
}
application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 10
max-idle: 5
min-idle: 2
cache:
type: redis
Cache Stampede
sync = true delegates loading coordination to the cache provider. The default Redis writer
in the configuration above is non-locking: do not claim that this annotation alone prevents
duplicate loads, even inside one application instance.
For expensive read-only loads, the optional
CoalescingRedisCacheManager uses a locking
writer with a finite lock TTL. Register it under a distinct bean name, keeping your normal
cache manager primary, and pass a JSON-serialized RedisCacheConfiguration:
@Cacheable(cacheManager = "coalescingCacheManager", value = "products", key = "#id", sync = true)
public ProductResponse findById(UUID id) { ... }
Add this bean method to the shown RedisConfig, reusing its jsonSerializer() helper,
and mark the normal cacheManager bean @Primary so existing annotations stay unchanged:
@Bean("coalescingCacheManager")
RedisCacheManager coalescingCacheManager(RedisConnectionFactory factory) {
var config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer()));
return CoalescingRedisCacheManager.create(factory, config, Duration.ofSeconds(10));
}
The writer lock is cache-wide, not per key: unrelated misses may block too. Configure finite loader/transport timeouts and a lock TTL longer than the expected load, and test contention. Lock expiry during a stalled load can still permit duplicates; this is load reduction, not an exactly-once guarantee or protection for payment/order mutations. Redis command timeouts do not bound the writer's entire lock-wait loop. Use a bounded caller deadline or a different coalescing strategy when strict waiting bounds are required.
Use sync loading only for committed, read-only data; transaction-aware put/evict does not make
the loader-based cache get transactional. Prefer TTL jitter or local coalescing when the
cache-wide lock would reduce throughput excessively. Do not implement a bare SETNX/delete
lock without ownership tokens, atomic owner-checked release and a lease-expiry design.
See Spring Redis cache behavior.
Failure policy
| Usage | Redis unavailable | Recovery concern |
|---|---|---|
| Disposable read cache | Optionally fall back to the source, with bounded concurrency | Avoid a database overload during an outage |
| Write-side eviction | Do not silently ignore failures | A committed DB update can leave stale cached values; use bounded TTL plus durable invalidation when needed |
| Authentication/session/authorization | Do not grant access because Redis failed | Return a controlled unavailable response or require reauthentication |
| Security rate limit | Fail closed or use an explicitly approved bounded fallback | Never silently allow unlimited traffic |
OptionalCacheLookup demonstrates narrow read-side
fallback for connection failures and timeouts, with an observability callback. It deliberately
does not suppress serialization errors or write/eviction failures. Recovery restores cache
reads; do not assume transactionAware() makes Redis and the database one atomic transaction.
Verification must include concurrent misses, TTL expiration, DTO round-trips (including money/time fields), rollback, outage fallback and recovery. A short negative-cache TTL can be useful for repeated misses; disabling null caching is an example choice, not a universal rule.
Verification scope
The repository fixture targets Boot 3.5.16 with Java 17/21 and checks Redis 7.4 serialization, TTL, normal concurrent loads and transaction callbacks; not failover or lease-expiry correctness. Check the coverage matrix before treating another minor version or an illustrative snippet as verified.
Gotchas
- Agent combines
@Transactionalwith@CachePut/@CacheEvicton a non-transaction-aware cache manager — the cache can be updated before commit (or evicted then re-filled with stale data); build it with.transactionAware() - Agent implements a rate limiter as separate
INCRthenEXPIREcalls — a failure between them leaves a TTL-less key that blocks the caller forever; use one Lua script - Agent allow-lists only
java.util.for default typing —BigDecimalis non-final and carries a type id, so cached prices/totals fail to deserialize; allowjava.math.(andjava.time.) too - Agent uses Java serialization for values — always use JSON (
GenericJackson2JsonRedisSerializer) - Agent uses
new GenericJackson2JsonRedisSerializer()— its plain mapper has no JSR-310 module, so anyInstant/LocalDatefield fails to serialize; build it withJavaTimeModule - Agent caches entities with JPA lazy fields — cache DTOs/response objects, not entities
- Agent uses no TTL — always set expiry, memory is not infinite
- Agent forgets
@EnableCaching—@Cacheablesilently does nothing without it - Agent caches misses without an expiry policy - either disable null caching or use an intentional short negative-cache TTL.
- Agent assumes
sync = trueguarantees a single load - verify the provider/writer and test concurrent misses. - Agent gives every entry the same TTL — add jitter so keys don't expire in a synchronized wave
Examples & templates
bad-cache-service.javaexamples
// ❌ BAD — caches entities, Java serialization, no TTL, missing @EnableCaching
// Missing @EnableCaching on configuration — annotations silently do nothing!
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Cacheable("orders") // no explicit key — relies on all params
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(); // caches JPA entity with lazy fields
// LazyInitializationException on cache hit!
}
@Cacheable("orders")
public Order findByIdNullable(UUID id) {
return orderRepository.findById(id)
.orElse(null); // caches null — every miss stores null permanently
}
// No @CacheEvict on update — stale data served forever
public Order update(UUID id, UpdateOrderRequest request) {
Order order = orderRepository.findById(id).orElseThrow();
order.update(request);
return orderRepository.save(order); // cache still has old version
}
// No TTL configured anywhere — cache grows unbounded until OOM
// Using default Java serialization — slow, fragile, breaks on class changes
}good-cache-service.javaexamples
// ✅ GOOD — caches DTOs, JSON serialization, TTL configured, proper eviction
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
@Cacheable(value = "orders", key = "#id")
public OrderResponse findById(UUID id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderResponse.from(order); // cache DTO, not entity
}
@Transactional
@CachePut(value = "orders", key = "#result.id()")
public OrderResponse update(UUID id, UpdateOrderRequest request) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.update(request);
return OrderResponse.from(orderRepository.save(order));
}
@Transactional
@CacheEvict(value = "orders", key = "#id")
public void delete(UUID id) {
orderRepository.deleteById(id);
}
@CacheEvict(value = "orders", allEntries = true)
public void evictAllOrders() {
// manual cache invalidation for admin use
}
}CoalescingRedisCacheManager.javatemplates
package com.example.config;
import java.time.Duration;
import org.springframework.data.redis.cache.BatchStrategies;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
/** Optional manager for expensive read-only loads, not a business-operation lock. */
public final class CoalescingRedisCacheManager {
private CoalescingRedisCacheManager() {}
public static RedisCacheManager create(RedisConnectionFactory factory,
RedisCacheConfiguration configuration, Duration lockTtl) {
if (lockTtl == null || lockTtl.isNegative() || lockTtl.isZero()) {
throw new IllegalArgumentException("A positive lock TTL is required");
}
var writer = RedisCacheWriter.lockingRedisCacheWriter(factory,
Duration.ofMillis(25), RedisCacheWriter.TtlFunction.just(lockTtl),
BatchStrategies.scan(1000));
return RedisCacheManager.builder(writer).cacheDefaults(configuration).build();
}
}OptionalCacheLookup.javatemplates
package com.example.config;
import java.util.Optional;
import java.util.function.Supplier;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.data.redis.RedisConnectionFailureException;
/** Read-only, disposable data caches. Never apply to authorization, sessions or rate limits. */
public final class OptionalCacheLookup {
private OptionalCacheLookup() {}
public static <T> Optional<T> read(Supplier<T> lookup, Runnable recordUnavailable) {
try {
return Optional.ofNullable(lookup.get());
} catch (RedisConnectionFailureException | QueryTimeoutException unavailable) {
try {
recordUnavailable.run();
} catch (RuntimeException observerFailure) {
org.slf4j.LoggerFactory.getLogger(OptionalCacheLookup.class)
.warn("Optional cache observer failed: {}", observerFailure.getClass().getSimpleName());
}
return Optional.empty();
}
}
}RedisConfig.javatemplates
package com.example.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.core.RedisTemplate;
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 RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jsonSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jsonSerializer());
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer()));
// Per-cache TTL overrides
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"orders", defaultConfig.entryTtl(Duration.ofMinutes(15)),
"products", defaultConfig.entryTtl(Duration.ofHours(1)),
"users", defaultConfig.entryTtl(Duration.ofMinutes(5))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.transactionAware() // put/evict only after the surrounding transaction commits
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
// Records are final, so DefaultTyping.NON_FINAL writes no type id for them and every
// cache read fails ("missing type id property '@class'"). The builder's defaultTyping(true)
// types user classes too; the validator limits type ids to an allow-list instead of
// LaissezFaire's "any class". BigDecimal is non-final, so java.math must be allowed.
private GenericJackson2JsonRedisSerializer jsonSerializer() {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.polymorphicTypeValidator(BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.") // your DTO packages
.allowIfSubType("java.util.")
.allowIfSubType("java.math.")
.allowIfSubType("java.time.")
.build())
.build();
return GenericJackson2JsonRedisSerializer.builder()
.objectMapper(mapper)
.defaultTyping(true)
.build();
}
}Verification scope
- Compiled sources
- Config, coalescing manager and optional lookup, both trees
- Runtime checks
- Redis 7.4: concurrent loads, TTL, serialization, transaction callbacks; injected outage failures and real recovered reads
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills