00:00

Spring Data Redis

Last updated:

Spring Data Redis connects a Spring Boot application to Redis, an in-memory data store that answers in well under a millisecond. Redis is more than a cache: it has hashes, sorted sets, counters and keys that delete themselves after a set time. That makes it a natural home for shopping carts, leaderboards, rate limits and short-lived offers. Spring Data Redis gives you templates for every Redis command and repositories for storing objects, without hand-written connection code.

This tutorial explains how Spring Data Redis works, the difference between StringRedisTemplate and RedisTemplate, the main Redis data types, expiry with TTL, JSON serialization, and repositories with @RedisHash. It then builds a shop backend on Spring Boot 4.1 with a cart, trending products, flash deals and delivery tracking. It also shows the real problems behind the most common pitfalls, and tests everything with @DataRedisTest and Testcontainers.

1. What Is Spring Data Redis?

Redis keeps all its data in memory and stores it under keys, such as cart:C-42. Each key holds one value of a specific data type: a string, a hash (a small map of fields), a list, a set or a sorted set. Every key can have a TTL (time to live), after which Redis deletes it automatically.

Spring Data Redis is the Spring module for Redis. It gives you:

  • Templates (StringRedisTemplate and RedisTemplate) with one method for each Redis command, grouped by data type.
  • Serializers that turn your Java keys and values into bytes, and back.
  • Repositories that save objects annotated with @RedisHash, like Spring Data JPA does for tables.
  • The cache support behind @Cacheable, covered separately in the Spring Boot caching tutorial.

2. How Spring Data Redis Works

Layers between your code and RedisThree APIs talk to Redis: StringRedisTemplate for UTF-8 strings used by the cart and trending products, a RedisTemplate of String to FlashDeal that stores JSON values, and DeliveryTrackingRepository for @RedisHash objects. All three share one LettuceConnectionFactory, a single thread-safe connection, which sends commands to Redis. In Redis, cart:C-42 is a hash with a 30-minute TTL, products:views is a sorted set, deal:KB-100 is a JSON string with a TTL, and deliveries:ORD-1001 is a hash plus index sets. StringRedisTemplate UTF-8 strings: cart, trending RedisTemplate<String, FlashDeal> JSON values: flash deals DeliveryTrackingRepository @RedisHash objects LettuceConnectionFactory one shared, thread-safe connection Redis (in memory) cart:C-42 → hash, TTL 30 min products:views → sorted set deal:KB-100 → JSON string, TTL deliveries:ORD-1001 → hash, plus index sets
Figure 1: templates and repositories share one Lettuce connection; each feature uses the Redis data type that fits it.

Spring Boot auto-configures a RedisConnectionFactory backed by Lettuce, a non-blocking Redis client whose connections are thread-safe and shared. On top of it, Boot creates two templates, stringRedisTemplate and redisTemplate, and enables Redis repositories. Every template call or repository method turns into Redis commands such as HINCRBY, ZINCRBY or SET ... EX.

3. Setup: Connecting Spring Boot to Redis

Add the Redis starter. The test dependencies are used in section 8:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
application.properties
spring.application.name=shop

spring.data.redis.host=localhost
spring.data.redis.port=6379
# Fail fast instead of hanging when Redis is slow or down
spring.data.redis.timeout=2s

For a managed Redis with a password and TLS, use a single URL such as spring.data.redis.url=rediss://default:${REDIS_PASSWORD}@cache.example.net:6380. The rediss scheme turns on TLS.

Version note: the properties have been spring.data.redis.* since Spring Boot 3.0. The old spring.redis.* keys are deprecated. Spring Boot 4 uses Spring Data Redis 4 and Jackson 3, so the JSON serializers are now JacksonJsonRedisSerializer and GenericJacksonJsonRedisSerializer. The Jackson 2 versions, Jackson2JsonRedisSerializer and GenericJackson2JsonRedisSerializer, are deprecated.

4. Core Concepts

4.1 StringRedisTemplate vs RedisTemplate

StringRedisTemplateRedisTemplate<K, V>
Key and value typesString onlyAny types you configure
SerializerUTF-8 stringsBoot's default bean uses JDK serialization (binary). Configure your own, e.g. JSON
Readable in redis-cliYesOnly with string or JSON serializers
Use forCounters, hashes of simple values, sorted sets, flagsStoring whole objects as values

Start with StringRedisTemplate. Reach for a typed RedisTemplate only when a value is a whole object, and then give it a JSON serializer (section 4.4).

4.2 Redis Data Types and Their Operations

Each template groups its commands by data type through an opsFor...() method:

Redis typeTemplate methodTypical commandsShop use case
StringopsForValue()SET, GET, INCRA flash deal, a page-view counter
HashopsForHash()HSET, HINCRBY, HGETALLA cart: SKU to quantity
ListopsForList()LPUSH, LRANGE, LTRIMRecently viewed products
SetopsForSet()SADD, SISMEMBERCustomers who used a coupon
Sorted setopsForZSet()ZINCRBY, ZREVRANGETrending products by views

Commands such as HINCRBY and ZINCRBY are atomic. Redis runs them one at a time, so two customers clicking "add to cart" at the same moment both count, without any locking in your code:

CartService.java (excerpt)
// HINCRBY is atomic, so two "add" clicks at once both count
Long quantity = redis.opsForHash().increment(key, request.sku(), request.quantity());
redis.expire(key, CART_TTL);

4.3 Expiring Keys with TTL

Give every key that should not live forever a TTL. Set it when you write the value, or afterwards with expire, and read the remaining time with getExpire:

TTL in CartService and FlashDealService (excerpts)
// Reset the TTL after every change to the cart
redis.expire("cart:" + customerId, Duration.ofMinutes(30));

// Seconds left: -1 = no TTL, -2 = key does not exist
Long ttl = redis.getExpire("cart:" + customerId);

// Write the value and its TTL in one command (SET ... EX)
flashDealTemplate.opsForValue().set("deal:" + sku, deal, Duration.ofSeconds(request.durationSeconds()));

The cart resets its 30-minute TTL on every change, so only abandoned carts expire. The flash deal is written with its duration in a single SET ... EX command, so it can never exist without an expiry.

4.4 Storing Objects as JSON

For whole objects, define a typed RedisTemplate with a string key serializer and a JSON value serializer. JacksonJsonRedisSerializer writes the record as plain JSON and reads it back into the same type:

RedisConfig.java
package com.javabytechie.shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import com.javabytechie.shop.deal.FlashDeal;

@Configuration
public class RedisConfig {

    // Keys as plain strings, values as JSON: both stay readable in redis-cli
    @Bean
    public RedisTemplate<String, FlashDeal> flashDealTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, FlashDeal> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(StringRedisSerializer.UTF_8);
        template.setValueSerializer(new JacksonJsonRedisSerializer<>(FlashDeal.class));
        return template;
    }
}

GenericJacksonJsonRedisSerializer is the alternative when one template stores many types. It adds type information to the JSON so it knows which class to create when reading.

4.5 Redis Repositories with @RedisHash

A Redis repository stores each object as a hash and lets you query it like a Spring Data repository. @RedisHash("deliveries") sets the key prefix, @Indexed makes a field searchable, and @TimeToLive sets a per-object TTL:

package com.javabytechie.shop.delivery;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;

// Stored as the hash "deliveries:<orderId>", plus an index set per customer
@RedisHash("deliveries")
public record DeliveryTracking(@Id String orderId,
                               @Indexed String customerId,
                               String status,
                               String courier,
                               @TimeToLive long ttlSeconds) {
}

Saving two deliveries for customer C-42 creates several keys. Redis has no secondary indexes of its own, so Spring Data maintains them as sets:

How a @RedisHash object is storedSaving the DeliveryTracking record with orderId ORD-1001, customerId C-42 (indexed), status, courier and ttlSeconds 3600 creates five keys. deliveries:ORD-1001 is a hash holding every field plus _class, with a TTL of 3600 seconds. deliveries is a set of all ids, used by findAll and count. deliveries:customerId:C-42 is a set of ids used by findByCustomerId. deliveries:ORD-1001:idx is a set listing which index sets mention this object. deliveries:ORD-1001:phantom is a copy of the hash with the TTL plus 5 minutes, created only when keyspace events are enabled. DeliveryTracking orderId = "ORD-1001" customerId = "C-42" (@Indexed) status, courier ttlSeconds = 3600 (@TimeToLive) deliveries:ORD-1001 hash: every field plus _class, TTL 3600 s deliveries set: all ids (ORD-1001, ORD-1002), used by findAll and count deliveries:customerId:C-42 set: ids for findByCustomerId("C-42") deliveries:ORD-1001:idx set: which index sets mention this object deliveries:ORD-1001:phantom hash copy, TTL + 5 min (only with keyspace events)
Figure 2: one save writes the hash plus the sets Spring Data uses as indexes; only the hash and its phantom copy expire on their own.
redis-cli
> SCAN 0
deliveries:ORD-1001
deliveries:ORD-1002
deliveries
deliveries:customerId:C-42
deliveries:ORD-1001:idx
deliveries:ORD-1002:idx
deliveries:ORD-1001:phantom
deliveries:ORD-1002:phantom

> HGETALL deliveries:ORD-1001
_class      com.javabytechie.shop.delivery.DeliveryTracking
courier     SwiftShip
customerId  C-42
orderId     ORD-1001
status      OUT_FOR_DELIVERY
ttlSeconds  3600

> SMEMBERS deliveries:customerId:C-42
ORD-1001
ORD-1002

Redis repositories support only simple queries: equality on @Indexed fields, combined with And or Or. There are no range queries or sorting on values. For anything more, use the templates directly.

5. A Complete Example: Cart, Trending Products and Flash Deals

The shop backend stores carts as hashes, ranks products by views in a sorted set and publishes flash deals as JSON values that expire. Errors follow one pattern: 404 for a deal that doesn't exist or has expired, and 400 for an invalid quantity, returned as ProblemDetail responses as described in the exception handling tutorial.

5.1 Cart

package com.javabytechie.shop.cart;

import java.time.Duration;
import java.util.Map;
import java.util.TreeMap;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class CartService {

    private static final Logger log = LoggerFactory.getLogger(CartService.class);

    // An abandoned cart disappears 30 minutes after its last change
    static final Duration CART_TTL = Duration.ofMinutes(30);

    private final StringRedisTemplate redis;

    public CartService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    private static String key(String customerId) {
        return "cart:" + customerId;
    }

    public CartResponse addItem(String customerId, AddItemRequest request) {
        if (request.quantity() < 1) {
            throw new InvalidQuantityException(request.quantity());
        }
        String key = key(customerId);
        // HINCRBY is atomic, so two "add" clicks at once both count
        Long quantity = redis.opsForHash().increment(key, request.sku(), request.quantity());
        redis.expire(key, CART_TTL);
        log.info("Cart {}: {} x {}", customerId, quantity, request.sku());
        return getCart(customerId);
    }

    public CartResponse removeItem(String customerId, String sku) {
        String key = key(customerId);
        redis.opsForHash().delete(key, sku);
        redis.expire(key, CART_TTL);
        return getCart(customerId);
    }

    public CartResponse getCart(String customerId) {
        String key = key(customerId);
        HashOperations<String, String, String> hash = redis.opsForHash();
        Map<String, Integer> items = new TreeMap<>();
        hash.entries(key).forEach((sku, quantity) -> items.put(sku, Integer.parseInt(quantity)));
        Long ttl = redis.getExpire(key);
        return new CartResponse(customerId, items, ttl == null || ttl < 0 ? 0 : ttl);
    }
}

5.2 Trending Products and Flash Deals

package com.javabytechie.shop.trending;

import java.util.List;
import java.util.Objects;
import java.util.Set;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
import org.springframework.stereotype.Service;

@Service
public class TrendingService {

    static final String VIEWS_KEY = "products:views";

    private final StringRedisTemplate redis;

    public TrendingService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    // ZINCRBY: the sorted set keeps products ordered by their view count
    public void recordView(String sku) {
        redis.opsForZSet().incrementScore(VIEWS_KEY, sku, 1);
    }

    public List<TrendingProduct> top(int limit) {
        Set<TypedTuple<String>> tuples = redis.opsForZSet().reverseRangeWithScores(VIEWS_KEY, 0, limit - 1);
        return Objects.requireNonNull(tuples).stream()
                .map(t -> new TrendingProduct(t.getValue(), Objects.requireNonNull(t.getScore()).longValue()))
                .toList();
    }
}

5.3 Error Handling and Application

package com.javabytechie.shop.config;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.javabytechie.shop.cart.InvalidQuantityException;
import com.javabytechie.shop.deal.DealNotFoundException;

@RestControllerAdvice
public class ShopExceptionHandler {

    @ExceptionHandler(DealNotFoundException.class)
    public ProblemDetail notFound(DealNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(InvalidQuantityException.class)
    public ProblemDetail badQuantity(InvalidQuantityException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
    }
}

5.4 Run It

Start Redis, for example with docker run -d -p 6379:6379 redis:8.2, then run the app with ./mvnw spring-boot:run. Add items to a cart. The second add of KB-100 increases its quantity from 2 to 3:

Terminal
$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":2}'
{"customerId":"C-42","items":{"KB-100":2},"expiresInSeconds":1800}

$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"MS-200","quantity":1}'
{"customerId":"C-42","items":{"KB-100":2,"MS-200":1},"expiresInSeconds":1800}

$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":1}'
{"customerId":"C-42","items":{"KB-100":3,"MS-200":1},"expiresInSeconds":1800}

$ curl -s -X DELETE localhost:8080/api/carts/C-42/items/MS-200
{"customerId":"C-42","items":{"KB-100":3},"expiresInSeconds":1800}

$ curl -s -i -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":0}'
HTTP/1.1 400
{"detail":"Quantity must be at least 1, but was 0","instance":"/api/carts/C-42/items","status":400,"title":"Bad Request"}

Record some product views, publish a deal and ask for one that doesn't exist:

Terminal
# MS-200 viewed 3 times, HD-300 twice, KB-100 once
$ curl -s -X POST localhost:8080/api/products/MS-200/views
...
$ curl -s 'localhost:8080/api/products/trending?limit=2'
[{"sku":"MS-200","views":3},{"sku":"HD-300","views":2}]

$ curl -s -X PUT localhost:8080/api/deals/KB-100 -H 'Content-Type: application/json' \
    -d '{"title":"Keyboard flash sale","price":69.00,"discountPercent":20,"durationSeconds":600}'
{"sku":"KB-100","title":"Keyboard flash sale","price":69.00,"discountPercent":20}

$ curl -s -i localhost:8080/api/deals/MS-200
HTTP/1.1 404
{"detail":"No active deal for product MS-200","instance":"/api/deals/MS-200","status":404,"title":"Not Found"}

Look at the raw data with redis-cli. The keys are readable, the cart is a hash with a TTL counting down, and the deal is plain JSON:

redis-cli
> SCAN 0
deal:KB-100
products:views
cart:C-42

> HGETALL cart:C-42
KB-100
3

> TTL cart:C-42
1793

> ZREVRANGE products:views 0 -1 WITHSCORES
MS-200
3
HD-300
2
KB-100
1

> GET deal:KB-100
{"sku":"KB-100","title":"Keyboard flash sale","price":69.00,"discountPercent":20}

> TTL deal:KB-100
593

6. Configuration Options

PropertyDefaultPurpose
spring.data.redis.host / portlocalhost / 6379Where Redis runs
spring.data.redis.urlNoneHost, port, credentials and TLS in one value; overrides the separate settings
spring.data.redis.username / passwordNoneAuthentication
spring.data.redis.database0Logical database number
spring.data.redis.timeoutLettuce's default (60s)How long a command may take before it fails
spring.data.redis.connect-timeoutLettuce's defaultHow long to wait for a connection
spring.data.redis.lettuce.pool.enabledEnabled if commons-pool2 is on the classpathConnection pool; rarely needed, because Lettuce shares one connection
spring.data.redis.repositories.enabledtrueTurns Redis repositories on or off

7. Common Pitfalls and How to Fix Them

7.1 Keys Look Like "\xac\xed\x00\x05t\x00..."

Spring Boot's default redisTemplate bean uses JdkSerializationRedisSerializer for keys and values. Writing "cart:C-99" through it stores a binary key that other tools, and even StringRedisTemplate, can't find:

Probe and redis-cli
redisTemplate.opsForValue().set("cart:C-99", "KB-100");   // Boot's default RedisTemplate<Object, Object>

value serializer:              JdkSerializationRedisSerializer
stringRedisTemplate.hasKey("cart:C-99")  ->  false

> SCAN 0
"\xac\xed\x00\x05t\x00\tcart:C-99"

How to fix it: use StringRedisTemplate, or a typed RedisTemplate with StringRedisSerializer keys and a JSON value serializer, as in section 4.4.

7.2 Expired Entities Still Counted by the Repository

When a @RedisHash object's TTL runs out, Redis deletes its hash but not the index sets Spring Data created. findById correctly returns nothing, but count() and the index sets still remember the object:

Probe and redis-cli (TTL of 1 second, no keyspace events)
before expiry:  findByCustomerId("C-55") = 1   count() = 1
after expiry:   findByCustomerId("C-55") = 0   count() = 1   findById("ORD-2001") = empty

> SCAN 0
deliveries:customerId:C-55
deliveries
deliveries:ORD-2001:idx

How to fix it: enable keyspace events with @EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP), as RedisRepositoryConfig does. Spring Data then listens for Redis "expired" events and removes the index entries. It keeps a :phantom copy of each object for 5 minutes longer than its TTL, so the data is still available when the event arrives. It also turns on Redis's notify-keyspace-events setting itself. Some managed Redis services block that command, so set it in the provider's configuration instead.

7.3 RedisConnectionFailureException: Unable to Connect to Redis

When Redis is down, the first command fails. Without keyspace events the app starts, and each request returns 500 with this cause. With keyspace events enabled, the listener connects at startup, so the app refuses to start at all:

Console (keyspace events enabled, Redis stopped)
ERROR o.s.boot.SpringApplication : Application run failed
Caused by: org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379
Caused by: java.net.ConnectException: Connection refused

How to fix it: check the host, port and password, and make sure Redis is reachable from where the app runs. Inside Docker, localhost is the container itself. Set spring.data.redis.timeout so a slow Redis fails quickly instead of blocking request threads.

7.4 Using KEYS in Production

KEYS cart:* walks through every key in one blocking step. On a large database it stalls Redis for everyone. How to fix it: use SCAN, which returns keys in small batches (redisTemplate.scan(ScanOptions.scanOptions().match("cart:*").build())), or keep your own set of the keys you need.

7.5 Keys That Never Expire

Redis holds everything in memory. Carts, sessions and counters written without a TTL accumulate until Redis runs out of memory and, depending on its maxmemory-policy, starts rejecting writes or evicting other keys. How to fix it: give every temporary key a TTL, and check with TTL key; -1 means the key never expires.

7.6 Treating Redis as the Only Copy of Important Data

Redis can save to disk, but depending on its persistence settings a crash can lose recent writes. How to fix it: keep orders, payments and other records that must not be lost in a durable database, such as PostgreSQL or MongoDB with Spring Data. Use Redis for data you can rebuild or afford to lose.

8. Testing with @DataRedisTest and Testcontainers

@DataRedisTest starts only the Redis part of the application: the connection factory, the templates and Redis repositories. It does not load your own @Configuration classes or services, so import them. A Redis container from Testcontainers provides the server. Testcontainers has no Redis-specific class here, so a GenericContainer is used, and @ServiceConnection(name = "redis") tells Spring Boot what it is. The Testcontainers tutorial explains this setup in detail:

TestcontainersConfiguration.java
package com.javabytechie.shop;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    // GenericContainer has no Redis-specific type, so name tells Spring Boot what it is
    @Bean
    @ServiceConnection(name = "redis")
    GenericContainer<?> redisContainer() {
        return new GenericContainer<>(DockerImageName.parse("redis:8.2")).withExposedPorts(6379);
    }
}

The service test empties Redis before each test and checks state before and after each action. That covers the cart quantity going from 2 to 3, an invalid quantity leaving no key behind, the trending order, and a one-second deal that really disappears:

ShopRedisServicesTest.java
package com.javabytechie.shop.cart;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.util.Map;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.redis.test.autoconfigure.DataRedisTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.RedisConfig;
import com.javabytechie.shop.deal.DealNotFoundException;
import com.javabytechie.shop.deal.FlashDeal;
import com.javabytechie.shop.deal.FlashDealService;
import com.javabytechie.shop.deal.PublishDealRequest;
import com.javabytechie.shop.trending.TrendingProduct;
import com.javabytechie.shop.trending.TrendingService;

@DataRedisTest
@Import({TestcontainersConfiguration.class, RedisConfig.class,
        CartService.class, TrendingService.class, FlashDealService.class})
class ShopRedisServicesTest {

    @Autowired
    StringRedisTemplate redis;

    @Autowired
    CartService cartService;

    @Autowired
    TrendingService trendingService;

    @Autowired
    FlashDealService flashDealService;

    @BeforeEach
    void emptyRedis() {
        redis.execute((RedisCallback<Void>) connection -> {
            connection.serverCommands().flushDb();
            return null;
        });
    }

    @Test
    void addingTheSameItemTwiceIncreasesQuantityAndSetsTtl() {
        assertThat(cartService.getCart("C-42").items()).isEmpty();

        cartService.addItem("C-42", new AddItemRequest("KB-100", 2));
        CartResponse cart = cartService.addItem("C-42", new AddItemRequest("KB-100", 1));

        assertThat(cart.items()).isEqualTo(Map.of("KB-100", 3));
        assertThat(cart.expiresInSeconds()).isBetween(1L, CartService.CART_TTL.toSeconds());
    }

    @Test
    void removingAnItemKeepsTheRest() {
        cartService.addItem("C-42", new AddItemRequest("KB-100", 1));
        cartService.addItem("C-42", new AddItemRequest("MS-200", 2));
        assertThat(cartService.getCart("C-42").items()).hasSize(2);

        assertThat(cartService.removeItem("C-42", "KB-100").items()).isEqualTo(Map.of("MS-200", 2));
    }

    @Test
    void invalidQuantityChangesNothing() {
        assertThatThrownBy(() -> cartService.addItem("C-42", new AddItemRequest("KB-100", 0)))
                .isInstanceOf(InvalidQuantityException.class);

        assertThat(redis.hasKey("cart:C-42")).isFalse();
    }

    @Test
    void trendingOrdersProductsByViews() {
        assertThat(trendingService.top(3)).isEmpty();

        for (int i = 0; i < 3; i++) trendingService.recordView("MS-200");
        trendingService.recordView("KB-100");
        for (int i = 0; i < 2; i++) trendingService.recordView("HD-300");

        assertThat(trendingService.top(2)).containsExactly(
                new TrendingProduct("MS-200", 3), new TrendingProduct("HD-300", 2));
    }

    @Test
    void dealIsStoredAsJsonAndExpires() throws InterruptedException {
        flashDealService.publish("KB-100",
                new PublishDealRequest("Keyboard flash sale", new BigDecimal("69.00"), 20, 1));

        assertThat(flashDealService.find("KB-100"))
                .isEqualTo(new FlashDeal("KB-100", "Keyboard flash sale", new BigDecimal("69.00"), 20));
        assertThat(redis.opsForValue().get("deal:KB-100")).startsWith("{\"sku\":\"KB-100\"");

        Thread.sleep(1500);

        assertThatThrownBy(() -> flashDealService.find("KB-100")).isInstanceOf(DealNotFoundException.class);
    }
}

The repository test checks the stored hash, the index query and the clean-up after expiry from section 7.2:

DeliveryTrackingRepositoryTest.java
package com.javabytechie.shop.delivery;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.redis.test.autoconfigure.DataRedisTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.core.StringRedisTemplate;

import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.RedisRepositoryConfig;

@DataRedisTest
@Import({TestcontainersConfiguration.class, RedisRepositoryConfig.class})
class DeliveryTrackingRepositoryTest {

    @Autowired
    DeliveryTrackingRepository repository;

    @Autowired
    StringRedisTemplate redis;

    @BeforeEach
    void clean() {
        repository.deleteAll();
    }

    @Test
    void savesAsHashAndFindsByIndexedField() {
        assertThat(repository.count()).isZero();

        repository.save(new DeliveryTracking("ORD-1001", "C-42", "OUT_FOR_DELIVERY", "SwiftShip", 3600));
        repository.save(new DeliveryTracking("ORD-1002", "C-42", "PACKED", "SwiftShip", 3600));
        repository.save(new DeliveryTracking("ORD-1003", "C-77", "DELIVERED", "RoadRunner", 3600));

        assertThat(repository.count()).isEqualTo(3);
        assertThat(repository.findByCustomerId("C-42"))
                .extracting(DeliveryTracking::orderId)
                .containsExactlyInAnyOrder("ORD-1001", "ORD-1002");
        assertThat(redis.opsForHash().get("deliveries:ORD-1001", "status")).isEqualTo("OUT_FOR_DELIVERY");
        assertThat(redis.getExpire("deliveries:ORD-1001")).isBetween(1L, 3600L);
    }

    @Test
    void expiredDeliveryDisappearsFromIndexesToo() throws InterruptedException {
        repository.save(new DeliveryTracking("ORD-2001", "C-55", "PACKED", "SwiftShip", 1));
        assertThat(repository.findByCustomerId("C-55")).hasSize(1);
        assertThat(redis.hasKey("deliveries:customerId:C-55")).isTrue();

        Thread.sleep(2500);

        assertThat(repository.findById("ORD-2001")).isEmpty();
        assertThat(repository.count()).isZero();
        assertThat(redis.hasKey("deliveries:customerId:C-55")).isFalse();
    }
}
Console
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.cart.ShopRedisServicesTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.delivery.DeliveryTrackingRepositoryTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

9. When to Use Redis (and When Not To)

Good fitPoor fit
Short-lived data with a natural expiry: carts, sessions, one-time codes, dealsRecords that must never be lost, such as orders and payments
Counters, rate limits and leaderboards that change constantlyComplex queries, joins, ranges and reports
Caching slow database or API resultsLarge datasets that don't fit in memory affordably
Sharing state between several instances of your appData used by only one instance, where a local in-memory cache is simpler

10. Best Practices

  • Prefer StringRedisTemplate, and give any typed RedisTemplate string keys and JSON values.
  • Name keys with a prefix and an id, such as cart:C-42, so they are easy to find and scan.
  • Set a TTL on every temporary key, ideally in the same command that writes it.
  • Use atomic commands such as HINCRBY, INCR and ZINCRBY instead of read-modify-write.
  • Pick the data type that fits: hashes for objects with fields, sorted sets for rankings, sets for membership.
  • Enable keyspace events when repositories combine @TimeToLive with @Indexed.
  • Never use KEYS in production; use SCAN.
  • Set spring.data.redis.timeout so a slow Redis can't block request threads.
  • Keep the system of record elsewhere and treat Redis data as rebuildable.
  • Test against real Redis with Testcontainers, emptying the database before each test.

11. Summary

Spring Data Redis gives a Spring Boot application fast access to Redis through templates and repositories. Add the starter and set spring.data.redis.*. Use StringRedisTemplate for counters, hashes and sorted sets, and a typed RedisTemplate with JacksonJsonRedisSerializer for whole objects. Give temporary keys a TTL and rely on atomic commands for concurrent updates. Use @RedisHash repositories for simple objects with indexed lookups, with keyspace events enabled when entities expire. Test it all against a real Redis container with @DataRedisTest. The main recommendation: model each feature with the Redis data type that fits it, keep keys readable, and make every temporary key expire.