Spring Boot Skills
GitHub
Data & Persistence

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.md

Dependencies

<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 @Transactional with @CachePut/@CacheEvict on 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 INCR then EXPIRE calls — 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 — BigDecimal is non-final and carries a type id, so cached prices/totals fail to deserialize; allow java.math. (and java.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 any Instant/LocalDate field fails to serialize; build it with JavaTimeModule
  • 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 — @Cacheable silently 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 = true guarantees 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
FROM THE REPOSITORY

Examples & templates

bad-cache-service.javaexamples
Source on GitHub
// ❌ 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
Source on GitHub
// ✅ 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
Source on GitHub
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
Source on GitHub
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
Source on GitHub
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 evidence
ADD TO YOUR AGENT

Install Boot 3 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skills