From 0f867451d5e506c536e85a3d9f3cb66ff9076a06 Mon Sep 17 00:00:00 2001 From: minhducphung Date: Mon, 6 Apr 2026 14:18:44 +0700 Subject: [PATCH] feat(java-spring): add java-cache skill Covers Spring Cache with Caffeine and Redis for Boot 2.x and 3.x: - review mode: checks @EnableCaching presence, unbounded caches, missing eviction, JDK serialization in Redis, null caching, proxy bypass pitfalls - setup mode: Caffeine (single-instance) vs Redis (distributed) guidance - cacheable/evict/redis modes with full code examples - references/patterns.md: per-cache TTL config, @Cacheable/@CacheEvict/ @CachePut/@Caching patterns, conditional caching, Redis cluster/sentinel config, Actuator metrics endpoints, common pitfalls table Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +- .../java-spring/skills/java-cache/SKILL.md | 110 ++++++ .../skills/java-cache/references/patterns.md | 317 ++++++++++++++++++ 3 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 plugins/java-spring/skills/java-cache/SKILL.md create mode 100644 plugins/java-spring/skills/java-cache/references/patterns.md diff --git a/README.md b/README.md index 87f6e4b..dbda840 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A Claude Code plugin marketplace with 3 focused plugins for Java developers. All | Plugin | Skills | Commands | Agents | Install when | |---|---|---|---|---| | `java-core` | 14 | 2 | `java-architect`, `java-build-resolver` | Every Java project | -| `java-spring` | 8 | 2 | `java-spring-expert` | Spring Boot projects | +| `java-spring` | 9 | 2 | `java-spring-expert` | Spring Boot projects | | `java-quality` | 3 | 1 | `java-security-reviewer`, `java-performance-reviewer`, `java-test-engineer` | Quality enforcement | ## Quick Setup (5 minutes) @@ -77,6 +77,7 @@ Skills activate automatically based on context, or invoke them explicitly. | `/java-spring:java-openapi` | Generate or review OpenAPI/Swagger docs — `@Tag`, `@Operation`, `@Schema`, JWT auth scheme (springdoc v1/v2) | | `/java-spring:java-spring-ai` | Add AI features to Spring Boot — ChatClient, RAG, tool calling, memory (Spring AI 1.x / LangChain4J) | | `/java-spring:java-resilience` | Add Resilience4J patterns — circuit breaker, retry, rate limiter, bulkhead, timeout (Boot 2.x & 3.x) | +| `/java-spring:java-cache` | Add or review Spring Cache — Caffeine (single-instance) or Redis (distributed), @Cacheable/@CacheEvict/@CachePut | ### java-quality diff --git a/plugins/java-spring/skills/java-cache/SKILL.md b/plugins/java-spring/skills/java-cache/SKILL.md new file mode 100644 index 0000000..20cd323 --- /dev/null +++ b/plugins/java-spring/skills/java-cache/SKILL.md @@ -0,0 +1,110 @@ +--- +name: java-cache +description: Use when the user asks to add caching, configure Redis or Caffeine cache, use @Cacheable/@CacheEvict/@CachePut, optimize repeated database or API calls, or review existing Spring Boot cache configuration. +version: 1.0.0 +authors: [java-plugins contributors] +tags: [java, spring-boot, cache, redis, caffeine, spring-cache] +allowed-tools: [Read, Glob, Grep, Edit, Write] +--- + +# Spring Cache Skill + +Detect the cache provider in use, then apply the correct patterns. + +## Step 1 — Detect setup + +Check `pom.xml` or `build.gradle`: +- `spring-boot-starter-data-redis` → Redis (Lettuce client by default) +- `spring-boot-starter-cache` + `caffeine` → Caffeine (in-process) +- `spring-boot-starter-cache` only → Simple (ConcurrentHashMap, dev only) +- None present → offer to add (recommend Caffeine for single-instance, Redis for multi-instance/distributed) + +Check Spring Boot version: +- Boot 3.x → Lettuce 6.x, Caffeine 3.x +- Boot 2.x → Lettuce 5.x, Caffeine 2.x/3.x + +--- + +## Mode: `review` + +User asks to review existing cache configuration. Check for: + +- [ ] `@EnableCaching` present on a `@Configuration` class — missing it silently disables all cache annotations +- [ ] Cache names declared in `application.yml` with explicit TTL — no unnamed or unbounded caches +- [ ] `@Cacheable` methods are on Spring-managed beans (not `private`, not called within the same class — proxy bypass) +- [ ] Cache keys are deterministic — `@Cacheable(key = "#id")` not `#root.methodName` unless intentional +- [ ] `@CacheEvict` present wherever data is mutated — missing eviction causes stale cache +- [ ] `@CachePut` used to update cache on write — not a `@CacheEvict` + re-fetch pattern +- [ ] Redis serialization configured — default JDK serialization is not readable/portable; use `GenericJackson2JsonRedisSerializer` +- [ ] Redis TTL set — without `time-to-live`, entries never expire +- [ ] Caffeine `maximumSize` set — without it, cache grows unbounded and causes OOM +- [ ] Null values handled — `@Cacheable(unless = "#result == null")` to avoid caching nulls +- [ ] Cache metrics exposed: `management.metrics.cache.instrument=true` + +--- + +## Mode: `setup` + +User asks to add caching from scratch. + +### Caffeine (recommended for single-instance apps) +1. Add `spring-boot-starter-cache` + `com.github.ben-manes.caffeine:caffeine` +2. Add `@EnableCaching` to a `@Configuration` class +3. Configure cache specs in `application.yml` — set `maximum-size` and `expire-after-write` +4. Annotate service methods with `@Cacheable`, `@CacheEvict`, `@CachePut` + +### Redis (recommended for multi-instance / distributed) +1. Add `spring-boot-starter-data-redis` +2. Configure `spring.data.redis.host/port` (Boot 3.x) or `spring.redis.host/port` (Boot 2.x) +3. Configure `RedisCacheConfiguration` bean — set TTL and use `GenericJackson2JsonRedisSerializer` +4. Add `@EnableCaching` and annotate service methods + +See `references/patterns.md` for full configuration examples. + +--- + +## Mode: `cacheable` + +User asks to cache the result of a method. + +1. Place `@Cacheable(cacheNames = "products", key = "#id")` on the service method +2. The method must be on a Spring-managed bean and not `private` +3. The method must not call itself (proxy bypass) — extract to a separate bean if needed +4. Add `unless = "#result == null"` to avoid caching null results +5. Ensure the return type is `Serializable` (Redis) or any object (Caffeine) +6. Add a corresponding `@CacheEvict` on the update/delete method + +--- + +## Mode: `evict` + +User asks to invalidate/evict cache entries on data changes. + +- `@CacheEvict(cacheNames = "products", key = "#id")` — evict a single entry on update/delete +- `@CacheEvict(cacheNames = "products", allEntries = true)` — evict all entries (use sparingly) +- `@CacheEvict(beforeInvocation = true)` — evict before method runs (use when method may throw) +- For multi-cache eviction: `@Caching(evict = { @CacheEvict("products"), @CacheEvict("productList") })` + +--- + +## Mode: `redis` + +User asks specifically for Redis cache configuration. + +1. Add `spring-boot-starter-data-redis` +2. Configure connection: `spring.data.redis.host`, `spring.data.redis.port`, `spring.data.redis.password` +3. Define `RedisCacheManager` bean with: + - `GenericJackson2JsonRedisSerializer` for values (human-readable, portable) + - `StringRedisSerializer` for keys + - Default TTL + per-cache TTL overrides +4. Enable `@EnableCaching` +5. For Redis Cluster: set `spring.data.redis.cluster.nodes` +6. For Redis Sentinel: set `spring.data.redis.sentinel.master` and `nodes` + +--- + +## Output format + +For **review mode**: list findings as `[CRITICAL] / [HIGH] / [MEDIUM] / [LOW]` with file:line references. + +For **implementation modes**: show exact Maven/Gradle dependency, full `application.yml` block, and complete Java configuration + annotated example. State minimum Spring Boot version where it differs. diff --git a/plugins/java-spring/skills/java-cache/references/patterns.md b/plugins/java-spring/skills/java-cache/references/patterns.md new file mode 100644 index 0000000..e9a1445 --- /dev/null +++ b/plugins/java-spring/skills/java-cache/references/patterns.md @@ -0,0 +1,317 @@ +# Spring Cache — Reference Patterns + +## Caffeine Setup (single-instance) + +### Maven +```xml + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + +``` + +### application.yml +```yaml +spring: + cache: + type: caffeine + caffeine: + spec: maximumSize=500,expireAfterWrite=10m + cache-names: + - products + - categories + - userProfiles +``` + +### Per-cache TTL (Java config) +```java +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager(); + manager.setCacheNames(List.of("products", "categories", "userProfiles")); + manager.registerCustomCache("products", + Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(Duration.ofMinutes(10)) + .recordStats() // enable hit/miss metrics + .build()); + manager.registerCustomCache("userProfiles", + Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofMinutes(30)) + .build()); + // default spec for other caches + manager.setCaffeine(Caffeine.newBuilder().maximumSize(200).expireAfterWrite(Duration.ofMinutes(5))); + return manager; + } +} +``` + +--- + +## Redis Setup (multi-instance / distributed) + +### Maven (Spring Boot 3.x) +```xml + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + +``` + +### application.yml (Boot 3.x) +```yaml +spring: + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + lettuce: + pool: + max-active: 10 + max-idle: 5 + min-idle: 1 + cache: + type: redis + redis: + time-to-live: 10m + key-prefix: "myapp:" + use-key-prefix: true + cache-null-values: false +``` + +### application.yml (Boot 2.x) +```yaml +spring: + redis: + host: ${REDIS_HOST:localhost} + port: 6379 +``` + +### Redis CacheManager with JSON serialization +```java +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + RedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)) + .disableCachingNullValues(); + + // Per-cache TTL overrides + Map cacheConfigs = new HashMap<>(); + cacheConfigs.put("userProfiles", defaultConfig.entryTtl(Duration.ofMinutes(30))); + cacheConfigs.put("products", defaultConfig.entryTtl(Duration.ofMinutes(60))); + cacheConfigs.put("categories", defaultConfig.entryTtl(Duration.ofHours(6))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} +``` + +--- + +## Cache Annotations + +### @Cacheable — cache the result +```java +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository repository; + + // Cache by id; skip caching if result is null + @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null") + public ProductDto findById(Long id) { + return repository.findById(id) + .map(ProductDto::from) + .orElse(null); + } + + // Compound key + @Cacheable(cacheNames = "products", key = "#category + ':' + #page") + public Page findByCategory(String category, int page) { + return repository.findByCategory(category, PageRequest.of(page, 20)) + .map(ProductDto::from); + } +} +``` + +### @CacheEvict — remove on mutation +```java +@Service +@RequiredArgsConstructor +public class ProductService { + + // Evict single entry on update + @CacheEvict(cacheNames = "products", key = "#dto.id") + @Transactional + public ProductDto update(ProductDto dto) { + Product entity = repository.findById(dto.getId()).orElseThrow(); + entity.update(dto); + return ProductDto.from(entity); + } + + // Evict on delete + @CacheEvict(cacheNames = "products", key = "#id") + @Transactional + public void delete(Long id) { + repository.deleteById(id); + } + + // Evict all — after bulk import + @CacheEvict(cacheNames = "products", allEntries = true) + @Transactional + public void importAll(List products) { + repository.saveAll(products.stream().map(Product::from).toList()); + } +} +``` + +### @CachePut — update cache on write +```java +// Updates cache with the new value WITHOUT skipping the method +@CachePut(cacheNames = "products", key = "#result.id") +@Transactional +public ProductDto create(ProductDto dto) { + Product saved = repository.save(Product.from(dto)); + return ProductDto.from(saved); +} +``` + +### @Caching — multiple cache operations at once +```java +@Caching( + evict = { + @CacheEvict(cacheNames = "products", key = "#id"), + @CacheEvict(cacheNames = "productList", allEntries = true), + @CacheEvict(cacheNames = "categories", allEntries = true) + } +) +@Transactional +public void delete(Long id) { + repository.deleteById(id); +} +``` + +--- + +## Conditional Caching + +```java +// Only cache if result has items +@Cacheable(cacheNames = "search", key = "#query", + unless = "#result == null || #result.isEmpty()") +public List search(String query) { ... } + +// Only cache for authenticated users (SpEL on principal) +@Cacheable(cacheNames = "dashboard", + key = "#root.target.getCurrentUserId()", + condition = "#root.target.isAuthenticated()") +public DashboardDto getDashboard() { ... } +``` + +--- + +## Cache Metrics (Actuator) + +### application.yml +```yaml +management: + endpoints: + web: + exposure: + include: health,caches,metrics + metrics: + cache: + instrument: true # enables cache.gets, cache.puts, cache.evictions +``` + +### Useful metrics endpoints +``` +GET /actuator/caches # list all cache names + sizes +GET /actuator/caches/{cacheName} # inspect a specific cache +GET /actuator/metrics/cache.gets # hit/miss counts (Caffeine + Redis) +GET /actuator/metrics/cache.puts +GET /actuator/metrics/cache.evictions +``` + +--- + +## Redis Cluster & Sentinel + +### Cluster (application.yml) +```yaml +spring: + data: + redis: + cluster: + nodes: + - redis-node1:6379 + - redis-node2:6379 + - redis-node3:6379 + max-redirects: 3 +``` + +### Sentinel (application.yml) +```yaml +spring: + data: + redis: + sentinel: + master: mymaster + nodes: + - sentinel1:26379 + - sentinel2:26379 + - sentinel3:26379 + password: ${REDIS_PASSWORD} +``` + +--- + +## Common Pitfalls + +| Pitfall | Fix | +|---|---| +| `@EnableCaching` missing | Add to any `@Configuration` class | +| Calling `@Cacheable` from same class | Extract to a separate Spring bean | +| `private` method annotated | Make it `public` or `protected` | +| No TTL on Redis | Always set `time-to-live` | +| No `maximumSize` on Caffeine | Always set `maximumSize` to prevent OOM | +| JDK serialization in Redis | Use `GenericJackson2JsonRedisSerializer` | +| Caching null returns | Add `unless = "#result == null"` | +| No eviction on writes | Add `@CacheEvict` to every mutating method |