---
name: redis-setup
description: "Add Redis caching and rate limiting to an existing Spring Boot 4 Maven project — Spring Cache on Redis with per-cache TTLs, graceful cache failure, Redis-backed rate limiting, Testcontainers tests, compose wiring. Use when asked to add Redis, cache an endpoint or query, or rate limit an API."
---
# Redis Setup Skill
Adds Redis-backed caching and rate limiting to an existing Spring Boot 4 project — and proves the
cache actually intercepts calls before saying it's done. Scope: caching + rate limiting — this
skill does **not** cover Spring Session or Redis as a message broker.
`SKILL_DIR` = the directory containing this SKILL.md.
**Load `SKILL_DIR/references/redis-conventions.md` before writing anything** — serialization deep
dive, TTL strategy, cache patterns, stampede mitigation, and failure modes.
The default `RedisCacheManager` serializes values with JDK serialization (opaque binary blobs that
break on schema changes) and gives keys no TTL (a slow memory leak). Step 3 is the part everyone
skips, and skipping it is why "we added Redis" ends in an eviction fire drill six months later.
---
## Step 0 — Gather inputs
| Field | Required | Notes |
|-------|----------|-------|
| `caches` | Yes | cache names with TTLs, e.g. `jobs: 10m`, `job-details: 5m` |
| `defaultTtl` | No | default: `30m` — every key gets a TTL, no exceptions |
| `rateLimit` | No | `false` (default) — add Bucket4j interceptor; if true, get limits per tier |
| `cacheNullValues` | No | `false` (default) — see the reference for the trade-off |
---
## Step 1 — Read the project
```bash
grep -m1 -A1 'spring-boot-starter-parent' pom.xml
grep -n 'data-redis\|bucket4j\|testcontainers' pom.xml
grep -rn 'EnableCaching\|@Cacheable' src/main/java | head -10
ls src/main/resources/application.y*ml docker-compose.y*ml compose.y*ml 2>/dev/null
find src/test/java -name 'Base*IntegrationTest.java'
```
Confirm Spring Boot 4.x and note the base package. If Redis or caching config already exists, extend
it — do not generate a second `CacheManager` bean.
---
## Step 2 — Add dependencies
Add to `pom.xml`:
```xml
org.springframework.boot
spring-boot-starter-data-redis
com.redis
testcontainers-redis
2.2.4
test
```
The starter is BOM-managed, no version. `testcontainers-redis` is not in Boot's BOM — pin it, and
confirm `2.2.4` is still the latest before writing:
```bash
curl -s "https://central.sonatype.com/solrsearch/select?q=g:com.redis+AND+a:testcontainers-redis&rows=3&core=gav" \
| python3 -c "import json,sys; print(sorted(d['v'] for d in json.load(sys.stdin)['response']['docs']))"
```
If `rateLimit` is true, also add (not BOM-managed — pin both):
```xml
com.bucket4j
bucket4j_jdk17-core
8.20.0
com.bucket4j
bucket4j_jdk17-lettuce
8.20.0
```
Confirm `8.20.0` is still the latest `jdk17` line before writing:
```bash
curl -s "https://central.sonatype.com/solrsearch/select?q=g:com.bucket4j+AND+a:bucket4j_jdk17-lettuce&rows=3&core=gav" \
| python3 -c "import json,sys; print(sorted(d['v'] for d in json.load(sys.stdin)['response']['docs']))"
```
---
## Step 3 — Cache config (the part everyone skips)
Create `src/main/java//shared/config/CacheConfig.java`. Imports worth noting:
`GenericJacksonJsonRedisSerializer` from `org.springframework.data.redis.serializer` (Jackson 3),
`BasicPolymorphicTypeValidator` from `tools.jackson.databind.jsontype`, `SerializationPair` from
`org.springframework.data.redis.serializer.RedisSerializationContext`.
```java
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
var typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("") // restrict @class hints to the app's own types
.build();
// JDK serialization (the default) writes opaque blobs that break on record/schema changes
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(
GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(typeValidator)
.build()));
// one entry per cache from Step 0 — never a cache without a TTL
return RedisCacheManager.builder(factory)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(Map.of(
"jobs", defaults.entryTtl(Duration.ofMinutes(10)),
"job-details", defaults.entryTtl(Duration.ofMinutes(5))))
.build();
}
// a cache miss is a slow request; a cache error must not be a 500
@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler() {
@Override public void handleCacheGetError(RuntimeException e, Cache c, Object k) { warn("GET", c, k); }
@Override public void handleCachePutError(RuntimeException e, Cache c, Object k, Object v) { warn("PUT", c, k); }
@Override public void handleCacheEvictError(RuntimeException e, Cache c, Object k) { warn("EVICT", c, k); }
@Override public void handleCacheClearError(RuntimeException e, Cache c) { warn("CLEAR", c, null); }
private void warn(String op, Cache cache, Object key) {
log.warn("Cache {} failed, proceeding without cache: cache={} key={}", op, cache.getName(), key);
}
};
}
}
```
The old Jackson-2 `GenericJackson2JsonRedisSerializer` is gone in Spring Data Redis 4.x — any guide
or autocomplete that produces it is stale. Default typing embeds a `@class` hint so cached values
deserialize back to their real type instead of `LinkedHashMap`. For TTLs that must change without
redeploy, bind them with `@ConfigurationProperties` — pattern in the reference.
---
## Step 4 — Update application.yml
Add:
```yaml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: 2s
```
`spring.redis.*` is dead — Boot 4 only reads `spring.data.redis.*`. Skip the host/port entirely if
the project uses `spring-boot-docker-compose` (Step 9 wires the connection itself).
---
## Step 5 — Apply caching
On the service method, not the controller:
```java
@Cacheable(cacheNames = "job-details", key = "#id")
public JobResponse getJob(Long id) { ... }
@CachePut(cacheNames = "job-details", key = "#result.id")
public JobResponse updateJob(Long id, UpdateJobRequest request) { ... }
@CacheEvict(cacheNames = "job-details", key = "#id")
public void deleteJob(Long id) { ... }
```
Silent failures to check before declaring done: self-invocation (calling the method from another
method in the same class bypasses the Spring AOP proxy — the annotation does nothing) and
non-public/final methods (not proxied). Move cached methods onto their own bean.
Cache-aside (what the abstraction gives you) is the right default. Read-through, and when to skip
caching entirely, are covered in the reference.
---
## Step 6 — Rate limiting (only if `rateLimit` is true)
Bucket4j over a hand-rolled `INCR`+`EXPIRE`: token buckets bound bursts, have no fixed-window
boundary exploit (2x the limit fires at the window edge), and the Lettuce proxy manager updates
bucket state atomically across instances.
Create `src/main/java//shared/config/RateLimitConfig.java` (`Bucket4jLettuce` is in
`io.github.bucket4j.redis.lettuce`):
```java
@Configuration
public class RateLimitConfig {
@Bean(destroyMethod = "shutdown")
RedisClient rateLimitRedisClient(@Value("${spring.data.redis.host:localhost}") String host,
@Value("${spring.data.redis.port:6379}") int port) {
return RedisClient.create("redis://%s:%d".formatted(host, port));
}
@Bean
ProxyManager rateLimitProxyManager(RedisClient rateLimitRedisClient) {
// without expiration, every client that ever calls leaves a bucket key forever
return Bucket4jLettuce.casBasedBuilder(rateLimitRedisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build();
}
}
```
Create `src/main/java//shared/web/RateLimitInterceptor.java`:
```java
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final ProxyManager proxyManager;
public RateLimitInterceptor(ProxyManager proxyManager) {
this.proxyManager = proxyManager;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
BucketConfiguration config = BucketConfiguration.builder()
.addLimit(Bandwidth.builder().capacity(20).refillGreedy(20, Duration.ofMinutes(1)).build())
.build();
ConsumptionProbe probe = proxyManager.builder()
.build(("rl:ip:" + request.getRemoteAddr()).getBytes(StandardCharsets.UTF_8), () -> config)
.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
response.setHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
return true;
}
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(probe.getNanosToWaitForRefill() / 1_000_000_000 + 1));
return false;
}
}
```
Register it on a `WebMvcConfigurer` with `addPathPatterns("/api/**")` and adjust capacity/refill per
the limits from Step 0. Key by API key when present, IP as fallback — the reference covers the
two-tier shape and when to trust `X-Forwarded-For`.
---
## Step 7 — Testcontainers base test
If `BaseIntegrationTest` exists (from `spring-scaffold` or `spring-testing`), add a Redis bean to
the `IntegrationTestContainers` configuration it imports:
```java
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer(DockerImageName.parse("redis:8.10.2-alpine"));
}
```
Boot's `RedisContainerConnectionDetailsFactory` matches `RedisContainer` by type and sets the host and
port.
Tests extend `BaseIntegrationTest` as before — no separate base class, no
`@DynamicPropertySource`, and no `static {}` start. If the project's base class still declares a
`static @Container`, move that container into `IntegrationTestContainers` first (see
`spring-testing` Step 3): the JUnit extension stops it after the first test class while Spring keeps
the cached context.
`RedisContainer` is `com.redis.testcontainers.RedisContainer` from the Step 2 dependency. Before
writing, confirm `8.10.2-alpine` still exists:
```bash
curl -s "https://hub.docker.com/v2/repositories/library/redis/tags/8.10.2-alpine" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['name'], d['last_updated'][:10])"
```
If `BaseIntegrationTest` does not exist, create one following `spring-scaffold` first.
---
## Step 8 — Prove the cache works
A cache nobody verified is a rumor. Create
`src/test/java//job/JobServiceCacheIntegrationTest.java`:
```java
class JobServiceCacheIntegrationTest extends BaseIntegrationTest {
@Autowired
private JobService jobService;
@MockitoBean
private JobRepository jobRepository;
@Test
void should_serve_second_call_from_cache() {
when(jobRepository.findById(1L)).thenReturn(Optional.of(new Job(1L, "Backend Engineer")));
jobService.getJob(1L);
jobService.getJob(1L);
verify(jobRepository, times(1)).findById(1L);
}
}
```
Two calls in, one call through — that is the only acceptable proof. `@MockitoBean` is the Boot 4
test API (see `spring-testing`). If the assertion fails with `times(2)`, the usual suspects are
self-invocation (Step 5) or a cache name with no matching `@Cacheable`. If rate limiting was added,
add a boundary test too: fire `capacity + 5` requests, assert exactly `capacity` pass and 5 come
back 429 with a `Retry-After` header.
---
## Step 9 — docker-compose service
If `docker-compose.yml` exists, add a Redis service:
```yaml
redis:
image: redis:8.10.2-alpine # pinned; never :latest
ports:
- "6379:6379"
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
```
`allkeys-lru` because a cache should evict cold keys under pressure, not start refusing writes.
If the project has `spring-boot-docker-compose` on the classpath, Boot detects the `redis` image
and wires `RedisConnectionDetails` itself — the Step 4 host/port becomes a fallback for running
without compose, and no `depends_on` is needed.
---
## Step 10 — Run and report
```bash
./mvnw test
```
Lead with the verdict, then the board:
```
Redis wired · cache hit verified (2 calls in, 1 through)
━━━ REDIS-SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Starter ............... ✅ spring-boot-starter-data-redis (BOM-managed)
Serialization ......... ✅ GenericJacksonJsonRedisSerializer (Jackson 3, typed)
TTLs .................. ✅ default 30m + per-cache overrides
Failure mode .......... ✅ CacheErrorHandler logs and proceeds
Rate limiting ......... ✅ Bucket4j 8.20.0 via Lettuce — 429 boundary tested
Testcontainers ........ ✅ RedisContainer redis:8.10.2-alpine
Compose ............... ✅ redis:8.10.2-alpine, allkeys-lru, healthcheck
Cache proof ........... ✅ repository hit once for two service calls
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Next: tune per-cache TTLs against real hit rates, then add otel-setup to watch hit/miss and latency.
```
Mark a row ⚠ if it was configured but not observed (Docker unavailable, test skipped) and say why.
Never report the cache green from configuration alone — Step 8 is the evidence.