Spring BootRedis Integration
Spring Boot

Redis Integration

Spring Boot auto-configures Redis through spring-boot-starter-data-redis, providing RedisTemplate for low-level operations, Spring Cache abstraction for annotation-driven caching, Spring Session for distributed session storage, and Spring Data repositories for structured data access. This entry covers setup, RedisTemplate operations, caching with @Cacheable, pub/sub messaging, distributed locks, rate limiting, and session management.

Setup and Configuration

Add spring-boot-starter-data-redis and optionally the Lettuce connection pool dependency. Spring Boot auto-configures a LettuceConnectionFactory and a RedisTemplate. Configure the connection in application.yml. For production, enable connection pooling and configure timeouts explicitly.
XML
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Connection pooling (Lettuce requires commons-pool2) -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

# ── application.yml ────────────────────────────────────────────────────
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16       # max connections in pool
          max-idle: 8
          min-idle: 2
          max-wait: 1000ms     # max time to wait for connection

# ── Redis Sentinel (high availability) ───────────────────────────────
spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes:
          - sentinel-1:26379
          - sentinel-2:26379
          - sentinel-3:26379
      password: ${REDIS_PASSWORD}

# ── Redis Cluster ────────────────────────────────────────────────────
spring:
  data:
    redis:
      cluster:
        nodes:
          - redis-1:6379
          - redis-2:6379
          - redis-3:6379
        max-redirects: 3

// ── Custom RedisTemplate configuration ────────────────────────────────
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // Key serializer — human-readable keys
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // Value serializer — JSON for portability
        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate(
            RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

RedisTemplate Operations

RedisTemplate exposes operation objects for each Redis data structure: opsForValue() for strings, opsForHash() for hashes, opsForList() for lists, opsForSet() for sets, and opsForZSet() for sorted sets. Each operation object provides the full command set for that structure.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisOperationsService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate           stringTemplate;

    // ── String operations ─────────────────────────────────────────────
    public void stringOps() {
        ValueOperations<String, Object> ops = redisTemplate.opsForValue();

        // SET with TTL
        ops.set("user:1:profile", new UserProfile("Alice", "alice@example.com"),
            Duration.ofHours(1));

        // GET
        UserProfile profile = (UserProfile) ops.get("user:1:profile");

        // SET if absent (SETNX)
        Boolean set = ops.setIfAbsent("lock:order:42", "locked",
            Duration.ofSeconds(30));

        // Increment counter
        Long views = ops.increment("article:1:views");

        // GET and SET atomically
        Object previous = ops.getAndSet("user:1:token", "newToken");

        // Bulk GET
        List<Object> values = ops.multiGet(
            List.of("user:1:profile", "user:2:profile"));
    }

    // ── Hash operations ───────────────────────────────────────────────
    public void hashOps() {
        HashOperations<String, String, Object> ops =
            redisTemplate.opsForHash();

        // HSET multiple fields
        Map<String, Object> fields = Map.of(
            "name",  "Alice",
            "email", "alice@example.com",
            "role",  "ADMIN"
        );
        ops.putAll("user:1", fields);

        // HGET single field
        String name = (String) ops.get("user:1", "name");

        // HMGET multiple fields
        List<Object> values = ops.multiGet("user:1",
            List.of("name", "email"));

        // HGETALL
        Map<String, Object> all = ops.entries("user:1");

        // HDEL
        ops.delete("user:1", "role");

        // HINCRBY
        ops.increment("user:1", "loginCount", 1);
    }

    // ── List operations ───────────────────────────────────────────────
    public void listOps() {
        ListOperations<String, Object> ops = redisTemplate.opsForList();

        // RPUSH — append to right
        ops.rightPush("queue:emails", new EmailTask("Welcome", "alice@example.com"));
        ops.rightPushAll("queue:emails",
            new EmailTask("Promo", "bob@example.com"),
            new EmailTask("Alert", "carol@example.com"));

        // LPOP — consume from left (FIFO queue)
        EmailTask task = (EmailTask) ops.leftPop("queue:emails");

        // BLPOP — blocking pop with timeout
        EmailTask blocking = (EmailTask) ops.leftPop(
            "queue:emails", Duration.ofSeconds(5));

        // LRANGE — read without consuming
        List<Object> pending = ops.range("queue:emails", 0, -1);

        // LLEN
        Long size = ops.size("queue:emails");
    }

    // ── Set operations ────────────────────────────────────────────────
    public void setOps() {
        SetOperations<String, Object> ops = redisTemplate.opsForSet();

        // SADD
        ops.add("product:1:tags", "electronics", "sale", "featured");

        // SISMEMBER
        Boolean isMember = ops.isMember("product:1:tags", "sale");

        // SMEMBERS
        Set<Object> tags = ops.members("product:1:tags");

        // SINTER — intersection
        Set<Object> common = ops.intersect(
            "product:1:tags", "product:2:tags");

        // SUNION — union
        Set<Object> all = ops.union("product:1:tags", "product:2:tags");

        // SREM
        ops.remove("product:1:tags", "sale");
    }

    // ── Sorted set operations ─────────────────────────────────────────
    public void sortedSetOps() {
        ZSetOperations<String, Object> ops = redisTemplate.opsForZSet();

        // ZADD
        ops.add("leaderboard", "alice", 1500.0);
        ops.add("leaderboard", "bob",   1200.0);
        ops.add("leaderboard", "carol", 1800.0);

        // ZINCRBY
        ops.incrementScore("leaderboard", "alice", 100.0);

        // ZRANGE — ascending by score
        Set<Object> bottom = ops.range("leaderboard", 0, 9);

        // ZREVRANGE — descending (top 10)
        Set<Object> top10 = ops.reverseRange("leaderboard", 0, 9);

        // ZRANK — position (0-based)
        Long rank = ops.reverseRank("leaderboard", "alice");

        // ZSCORE
        Double score = ops.score("leaderboard", "alice");
    }
}

Caching with @Cacheable

Spring Cache abstraction decouples caching logic from business logic. Enable it with @EnableCaching, configure a RedisCacheManager with TTL per cache, and annotate service methods with @Cacheable, @CachePut, and @CacheEvict. Spring serialises return values to Redis and returns the cached value on subsequent calls without invoking the method.
Java
// ── Enable caching ────────────────────────────────────────────────────
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(
            RedisConnectionFactory factory) {

        // Default configuration
        RedisCacheConfiguration defaults = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // Per-cache TTL overrides
        Map<String, RedisCacheConfiguration> configs = Map.of(
            "products",    defaults.entryTtl(Duration.ofHours(1)),
            "users",       defaults.entryTtl(Duration.ofMinutes(15)),
            "categories",  defaults.entryTtl(Duration.ofHours(24)),
            "sessions",    defaults.entryTtl(Duration.ofMinutes(30))
        );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaults)
            .withInitialCacheConfigurations(configs)
            .build();
    }
}

// ── Service with cache annotations ────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {

    private final ProductRepository productRepo;

    // ── @Cacheablereturn cached value or invoke method and cache ────
    @Cacheable(value = "products", key = "#id")
    public ProductResponse findById(Long id) {
        log.debug("Cache miss — loading product {} from DB", id);
        return productRepo.findById(id)
            .map(ProductResponse::from)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    // ── Conditional caching ───────────────────────────────────────────
    @Cacheable(value = "products",
               key  = "#id",
               condition = "#id > 0",
               unless    = "#result.price > 1000")
    public ProductResponse findByIdConditional(Long id) {
        return productRepo.findById(id)
            .map(ProductResponse::from)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    // ── @CachePut — always invoke method, update cache ────────────────
    @CachePut(value = "products", key = "#result.id")
    @Transactional
    public ProductResponse update(Long id, UpdateProductRequest request) {
        Product product = productRepo.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));
        product.setName(request.name());
        product.setPrice(request.price());
        return ProductResponse.from(productRepo.save(product));
    }

    // ── @CacheEvict — remove entry on delete ─────────────────────────
    @CacheEvict(value = "products", key = "#id")
    @Transactional
    public void delete(Long id) {
        productRepo.deleteById(id);
    }

    // ── Evict all entries in a cache ──────────────────────────────────
    @CacheEvict(value = "products", allEntries = true)
    public void clearProductCache() {
        log.info("Cleared all product cache entries");
    }

    // ── @Caching — multiple cache operations on one method ───────────
    @Caching(evict = {
        @CacheEvict(value = "products",   key = "#id"),
        @CacheEvict(value = "categories", allEntries = true)
    })
    @Transactional
    public void deleteWithCategoryRefresh(Long id) {
        productRepo.deleteById(id);
    }
}

Pub/Sub Messaging

Redis pub/sub allows services to broadcast messages on channels and receive them asynchronously. Spring provides RedisMessageListenerContainer to manage listener threads and MessageListener / @RedisListener interfaces to receive messages. Use pub/sub for cache invalidation signals, real-time notifications, and lightweight event broadcasting.
Java
// ── Message model ─────────────────────────────────────────────────────
public record CacheInvalidationMessage(
    String entityType,
    Long   entityId,
    String action       // CREATED, UPDATED, DELETED
) {}

// ── Publisher ────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisPublisher {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper                  objectMapper;

    public void publish(String channel, Object message) {
        try {
            String json = objectMapper.writeValueAsString(message);
            redisTemplate.convertAndSend(channel, json);
            log.debug("Published to {}: {}", channel, json);
        } catch (JsonProcessingException e) {
            log.error("Failed to publish message to {}", channel, e);
        }
    }

    public void invalidateCache(String entityType,
                                 Long entityId, String action) {
        publish("cache:invalidation",
            new CacheInvalidationMessage(entityType, entityId, action));
    }
}

// ── Subscriber ────────────────────────────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheInvalidationSubscriber implements MessageListener {

    private final ObjectMapper   objectMapper;
    private final CacheManager   cacheManager;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String json = new String(message.getBody(),
                StandardCharsets.UTF_8);
            CacheInvalidationMessage event = objectMapper
                .readValue(json, CacheInvalidationMessage.class);

            Cache cache = cacheManager.getCache(
                event.entityType().toLowerCase() + "s");
            if (cache != null) {
                cache.evict(event.entityId());
                log.info("Evicted {} {} from cache",
                    event.entityType(), event.entityId());
            }
        } catch (Exception e) {
            log.error("Failed to process cache invalidation", e);
        }
    }
}

// ── Configuration — register listener container ───────────────────────
@Configuration
@RequiredArgsConstructor
public class RedisMessagingConfig {

    private final RedisConnectionFactory        factory;
    private final CacheInvalidationSubscriber   subscriber;

    @Bean
    public RedisMessageListenerContainer listenerContainer() {
        RedisMessageListenerContainer container =
            new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);

        // Subscribe to channel
        container.addMessageListener(subscriber,
            new ChannelTopic("cache:invalidation"));

        // Pattern subscription — matches cache:* channels
        container.addMessageListener(subscriber,
            new PatternTopic("cache:*"));

        return container;
    }

    @Bean
    public RedisSerializer<String> redisSerializer() {
        return new StringRedisSerializer();
    }
}

Distributed Locks

Redis distributed locks coordinate access to shared resources across multiple application instances. Implement the lock using SET NX PX (set if not exists with expiry) through RedisTemplate, or use Redisson's RLock which handles lock renewal, reentrance, and unlock safety automatically.
Java
// ── Simple lock with RedisTemplate ───────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisDistributedLock {

    private final StringRedisTemplate stringTemplate;

    private static final String LOCK_PREFIX = "lock:";

    /**
     * Try to acquire a lock. Returns true if acquired.
     * @param key      resource identifier
     * @param value    unique caller ID (e.g. UUID) — used to release safely
     * @param ttl      maximum lock hold time
     */
    public boolean tryAcquire(String key, String value, Duration ttl) {
        Boolean acquired = stringTemplate.opsForValue()
            .setIfAbsent(LOCK_PREFIX + key, value, ttl);
        return Boolean.TRUE.equals(acquired);
    }

    /**
     * Release the lock — only if we own it (compare-and-delete).
     */
    public boolean release(String key, String value) {
        String luaScript = """
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
            """;
        Long result = stringTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(LOCK_PREFIX + key),
            value);
        return Long.valueOf(1L).equals(result);
    }

    /**
     * Execute work under a distributed lock.
     */
    public <T> T executeWithLock(String key, Duration ttl,
                                  Supplier<T> work) {
        String lockValue = UUID.randomUUID().toString();
        if (!tryAcquire(key, lockValue, ttl)) {
            throw new LockNotAvailableException(
                "Could not acquire lock for: " + key);
        }
        try {
            return work.get();
        } finally {
            release(key, lockValue);
        }
    }
}

// ── Service using the distributed lock ───────────────────────────────
@Service
@RequiredArgsConstructor
public class InventoryService {

    private final RedisDistributedLock lock;
    private final InventoryRepository  inventoryRepo;

    public void reserveStock(Long productId, int quantity) {
        lock.executeWithLock(
            "inventory:" + productId,
            Duration.ofSeconds(10),
            () -> {
                InventoryItem item = inventoryRepo
                    .findByProductId(productId)
                    .orElseThrow();

                if (item.getStockLevel() < quantity) {
                    throw new InsufficientStockException(
                        productId, quantity, item.getStockLevel());
                }

                item.setStockLevel(item.getStockLevel() - quantity);
                inventoryRepo.save(item);
                return null;
            }
        );
    }
}

// ── Redisson distributed lock (production-grade) ──────────────────────
// <dependency>
//   <groupId>org.redisson</groupId>
//   <artifactId>redisson-spring-boot-starter</artifactId>
//   <version>3.25.2</version>
// </dependency>

@Service
@RequiredArgsConstructor
public class RedissonLockService {

    private final RedissonClient redisson;

    public void executeWithLock(String resource, Runnable work) {
        RLock lock = redisson.getLock("lock:" + resource);
        try {
            // Try for 5s, hold for max 30s, auto-renew via watchdog
            if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
                throw new LockNotAvailableException(
                    "Could not acquire lock for: " + resource);
            }
            work.run();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LockNotAvailableException("Lock interrupted");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

Rate Limiting

Redis is ideal for rate limiting — atomic Lua scripts ensure the check-and-increment operation is race-condition-free across multiple application instances. The sliding window and token bucket are the two most common algorithms. Use rate limiting to protect REST endpoints from abuse and to enforce per-user quotas.
Java
// ── Fixed window rate limiter ─────────────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisRateLimiter {

    private final StringRedisTemplate stringTemplate;

    /**
     * Fixed window: allow maxRequests per window duration.
     * Returns true if the request is allowed.
     */
    public boolean isAllowed(String key, int maxRequests,
                              Duration window) {
        String redisKey = "rate:" + key;
        String luaScript = """
            local current = redis.call('INCR', KEYS[1])
            if current == 1 then
                redis.call('EXPIRE', KEYS[1], ARGV[2])
            end
            if current > tonumber(ARGV[1]) then
                return 0
            end
            return 1
            """;

        Long result = stringTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(redisKey),
            String.valueOf(maxRequests),
            String.valueOf(window.toSeconds()));

        return Long.valueOf(1L).equals(result);
    }

    /**
     * Sliding window: count requests in the last windowSeconds.
     */
    public boolean isAllowedSlidingWindow(String key,
            int maxRequests, int windowSeconds) {
        String redisKey = "rate:sliding:" + key;
        long now = System.currentTimeMillis();
        long windowStart = now - (windowSeconds * 1000L);

        String luaScript = """
            redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
            local count = redis.call('ZCARD', KEYS[1])
            if count < tonumber(ARGV[3]) then
                redis.call('ZADD', KEYS[1], ARGV[2], ARGV[2])
                redis.call('EXPIRE', KEYS[1], ARGV[4])
                return 1
            end
            return 0
            """;

        Long result = stringTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            List.of(redisKey),
            String.valueOf(windowStart),
            String.valueOf(now),
            String.valueOf(maxRequests),
            String.valueOf(windowSeconds + 1));

        return Long.valueOf(1L).equals(result);
    }
}

// ── Rate limit filter — applied to every request ──────────────────────
@Component
@RequiredArgsConstructor
@Order(1)
@Slf4j
public class RateLimitFilter extends OncePerRequestFilter {

    private final RedisRateLimiter rateLimiter;

    @Override
    protected void doFilterInternal(HttpServletRequest  request,
                                    HttpServletResponse response,
                                    FilterChain         chain)
            throws ServletException, IOException {

        String clientIp = getClientIp(request);
        String key      = "ip:" + clientIp;

        if (!rateLimiter.isAllowed(key, 100, Duration.ofMinutes(1))) {
            log.warn("Rate limit exceeded for IP: {}", clientIp);
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write(
                "{"error":"Too Many Requests"," +
                ""message":"Rate limit exceeded. " +
                "Try again in 1 minute."}");
            return;
        }

        chain.doFilter(request, response);
    }

    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        return forwarded != null
            ? forwarded.split(",")[0].trim()
            : request.getRemoteAddr();
    }
}

// ── Per-user rate limit in a controller ───────────────────────────────
@GetMapping("/api/v1/reports/generate")
public ResponseEntity<ReportResponse> generate(
        @AuthenticationPrincipal UserDetails user) {

    if (!rateLimiter.isAllowedSlidingWindow(
            "user:" + user.getUsername(), 10, 60)) {
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("Retry-After", "60")
            .build();
    }

    return ResponseEntity.ok(reportService.generate());
}

Spring Session with Redis

Spring Session replaces the HTTP session with a Redis-backed store, enabling stateless application instances to share session state. Add spring-session-data-redis, annotate the configuration with @EnableRedisHttpSession, and Spring handles session serialisation, expiry, and retrieval transparently.
XML
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

# ── application.yml ────────────────────────────────────────────────────
spring:
  session:
    store-type: redis
    redis:
      namespace: myapp:session    # Redis key prefix
      flush-mode: on-save         # or IMMEDIATE
    timeout: 30m                  # session expiry

// ── Enable Redis HTTP session ─────────────────────────────────────────
@Configuration
@EnableRedisHttpSession(
    maxInactiveIntervalInSeconds = 1800,   // 30 minutes
    redisNamespace               = "myapp:session",
    flushMode                    = FlushMode.ON_SAVE
)
public class SessionConfig {

    // Spring Session creates its own serializer — configure if needed:
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

// ── Session usage in a controller ────────────────────────────────────
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<UserResponse> login(
            @RequestBody @Valid LoginRequest request,
            HttpSession session) {

        UserResponse user = authService.authenticate(request);

        // Store in Redis-backed session
        session.setAttribute("currentUser", user);
        session.setAttribute("loginTime",
            LocalDateTime.now().toString());
        session.setMaxInactiveInterval(1800);   // 30 min

        return ResponseEntity.ok(user);
    }

    @GetMapping("/me")
    public ResponseEntity<UserResponse> me(HttpSession session) {
        UserResponse user =
            (UserResponse) session.getAttribute("currentUser");
        if (user == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok(user);
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(HttpSession session) {
        session.invalidate();    // removes from Redis immediately
        return ResponseEntity.noContent().build();
    }
}

// ── Session event listener ────────────────────────────────────────────
@Component
@Slf4j
public class SessionEventListener {

    @EventListener
    public void onSessionCreated(SessionCreatedEvent event) {
        log.info("Session created: {}", event.getSessionId());
    }

    @EventListener
    public void onSessionExpired(SessionExpiredEvent event) {
        log.info("Session expired: {}", event.getSessionId());
    }

    @EventListener
    public void onSessionDeleted(SessionDeletedEvent event) {
        log.info("Session deleted: {}", event.getSessionId());
    }
}

Redis Repository

Spring Data Redis repositories map Java objects to Redis hashes with @RedisHash. They support CRUD operations, TTL via @TimeToLive, and secondary indexes via @Indexed. Use them for lightweight entities that do not need a relational database — tokens, sessions, rate limit counters, and ephemeral domain objects.
Java
// ── Redis entity ──────────────────────────────────────────────────────
@RedisHash(value = "refresh_tokens", timeToLive = 604800)  // 7 days
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class RefreshToken {

    @Id
    private String token;          // Redis key: refresh_tokens:<token>

    @Indexed
    private String userId;         // secondary index for lookup by userId

    @Indexed
    private String deviceId;

    private String userAgent;
    private String ipAddress;
    private LocalDateTime createdAt;
    private LocalDateTime lastUsedAt;

    @TimeToLive
    private Long ttl;              // per-instance TTL override (seconds)
}

// ── Repository ────────────────────────────────────────────────────────
public interface RefreshTokenRepository
        extends CrudRepository<RefreshToken, String> {

    List<RefreshToken> findByUserId(String userId);
    List<RefreshToken> findByUserIdAndDeviceId(
        String userId, String deviceId);
    void deleteByUserId(String userId);
}

// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {

    private final RefreshTokenRepository tokenRepo;

    public RefreshToken create(String userId, String deviceId,
                                HttpServletRequest request) {
        RefreshToken token = new RefreshToken(
            UUID.randomUUID().toString(),
            userId,
            deviceId,
            request.getHeader("User-Agent"),
            getClientIp(request),
            LocalDateTime.now(),
            LocalDateTime.now(),
            null    // use default TTL from @RedisHash
        );
        return tokenRepo.save(token);
    }

    public RefreshToken validate(String tokenValue) {
        return tokenRepo.findById(tokenValue)
            .orElseThrow(() -> new InvalidTokenException(
                "Refresh token not found or expired"));
    }

    public void revokeAll(String userId) {
        tokenRepo.deleteByUserId(userId);
        log.info("Revoked all refresh tokens for user {}", userId);
    }

    public List<RefreshToken> findActiveSessions(String userId) {
        return tokenRepo.findByUserId(userId);
    }

    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        return forwarded != null
            ? forwarded.split(",")[0].trim()
            : request.getRemoteAddr();
    }
}