Most Redis client code still treats a value as an opaque blob: read the whole thing, deserialize it, change one field in Java, serialize it, write the whole thing back. Spring Data Redis’s new JsonOperations skips that round trip entirely — it talks the RedisJSON module’s own commands (JSON.SET, JSON.GET, JSON.ARRAPPEND, …), which read and mutate a document inside Redis, server-side, addressed by a JSONPath expression. You can fetch one field, append to a nested array, or read a computed slice of a document without ever pulling the rest of it over the wire.
This post builds a small shopping-cart service around it: a whole-document save and read, a single-field path read, an array append that never touches the rest of the document, and a delete — each backed by a real Redis 8 container and a passing test.
Versions used in this post. Spring Boot 4.1.1 · Spring Data Redis 4.2.0-M2 (milestone — see below) · Jackson 2jackson-databind(added explicitly) · Redis server 8.8.3 · JDK 25 (Temurin) · Testcontainers 2.0.5.JsonOperations/RedisJsonTemplatedoes not exist at all in the 4.1.1 GA line that Spring Boot 4.1.1’s own BOM manages — confirmed by inspecting the resolved 4.1.1 jar withjavap, not assumed from docs. It ships first in the 4.2 milestone train, so this post pins that one artifact to4.2.0-M2on top of an otherwise-ordinary Boot 4.1.1 project. That override is the actual subject of this post, not an oversight.
This demo is small enough that it doesn’t warrant its own repository module. Every source file is linked for download right where it’s discussed below, and the full set is also uploaded to this site’s media library if you’d rather grab everything at once.
Why Spring Boot 4.1.1 can’t see JsonOperations
Boot 4.1.1’s parent BOM manages spring-data-redis at the 4.1.1 GA line. Running javap -p against the resolved 4.1.1 jar turns up no RedisJsonOperations or RedisJsonTemplate class at all — it isn’t a visibility or autoconfiguration problem, the classes simply aren’t built yet in that line. They show up first in the 4.2 milestone train, so the pom overrides that one dependency’s version while leaving everything else managed by Boot’s BOM:
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>4.2.0-M2</version>
</dependency>
Download: pom.xml.
Going deeper: the Jackson 2 vs Jackson 3 classpath trap
spring-boot-starter-data-redis does not pull in any Jackson module by itself — unlike spring-boot-starter-web, which does. Without one, the RedisJsonTemplate.create(...) bean fails at startup with IllegalStateException: No default RedisJsonSerializer available. Add Jackson 2 (com.fasterxml) or 3 (tools.jackson) to the classpath.
The error names two options, so the obvious first attempt is Boot’s own spring-boot-starter-json, which resolves to Jackson 3 (tools.jackson:jackson-databind). That starts cleanly — and then fails at the first actual JSON write with NoClassDefFoundError: com/fasterxml/jackson/databind/JsonNode. The milestone’s default RedisJsonSerializer is wired specifically against Jackson 2’s com.fasterxml.jackson.databind package, not Jackson 3’s tools.jackson package, so satisfying the error message’s own “or 3” branch at bean-creation time still leaves the real runtime dependency unmet. The fix that actually works is dropping spring-boot-starter-json and depending on com.fasterxml.jackson.core:jackson-databind (Jackson 2) directly, with no version — Boot’s parent BOM manages it.
The domain: a cart as one JSON document
public record Cart(String cartId, String customerId, List<CartItem> items) {
public record CartItem(String sku, int quantity, BigDecimal unitPrice) {}
}
Download: Cart.java. The whole cart, items array included, is one JSON document under one Redis key — there’s no separate key per item and no join to perform on read.
@Configuration
public class RedisJsonConfig {
@Bean
public RedisJsonOperations<String> redisJsonOperations(RedisConnectionFactory connectionFactory) {
return RedisJsonTemplate.create(connectionFactory);
}
}
Download: RedisJsonConfig.java · application entry point: RedisJsonDemoApplication.java.
The service: each method is one round trip
@Service
public class CartJsonService {
private final RedisJsonOperations<String> json;
public CartJsonService(RedisJsonOperations<String> json) {
this.json = json;
}
public void saveCart(Cart cart) {
json.value(key(cart.cartId())).set(cart);
}
public Cart getCart(String cartId) {
return json.value(key(cartId)).get().as(Cart.class);
}
public String getCustomerId(String cartId) {
return json.value(key(cartId)).path("$.customerId").get().as(String.class);
}
public List<Long> addItem(String cartId, Cart.CartItem item) {
return json.array(key(cartId)).path("$.items").append(item);
}
public BigDecimal total(String cartId) {
List<Cart.CartItem> items = json.value(key(cartId)).path("$.items[*]")
.get().matches().as(Cart.CartItem.class);
return items.stream()
.map(i -> i.unitPrice().multiply(BigDecimal.valueOf(i.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public boolean deleteCart(String cartId) {
return Boolean.TRUE.equals(json.delete(key(cartId)));
}
private String key(String cartId) {
return "cart:" + cartId;
}
}
Download: CartJsonService.java. Every method above is exactly one command to Redis — there is no “fetch, mutate in Java, write back” step anywhere in this class, because JsonOperations has no vocabulary for doing it that way.
Saving a cart and reading it back, whole document both ways:
saved cart-1 with 2 items, customerId=cust-77
read back: cartId=cart-1 customerId=cust-77 items=2
Reading just the customer id from a different cart, by path, without touching its items array at all:
JSON.GET cart:cart-2 $.customerId -> "cust-88"
Appending a second item to a cart that already has one, and recomputing the total from a single JSONPath read over the array:
JSON.ARRAPPEND cart:cart-3 $.items <new item> -> new array length = 2
item_count_after_append=2
total=40.0 (10.00 x1 + 15.00 x2, computed from the array in one JSONPath read)
And deleting — the first call removes the document, the second finds nothing left to remove:
delete cart-4 (exists) -> true
delete cart-4 (already gone) -> false
Going deeper: why the round-trip test compares fields, not the whole record
The whole-document test saves a cart with a BigDecimal("49.00") unit price and reads it back. A first version of that test asserted isEqualTo on the whole Cart record and failed: expected unitPrice=49.00 but was unitPrice=49.0. JSON’s number syntax has no concept of decimal scale, so Jackson’s round trip through JSON.SET/JSON.GET is free to normalize 49.00 to 49.0 — numerically identical, but a Java record’s generated equals() compares BigDecimal fields with equals() too, which treats different scales as different values. The fix is asserting cartId/customerId/item count and each item’s sku/quantity with ordinary isEqualTo, and each item’s unitPrice specifically with AssertJ’s isEqualByComparingTo, which compares numeric value rather than scale.
Redis 8 ships RedisJSON built in
None of this needed a separate “stack” image. Running MODULE LIST against a plain redis:8.8.3 container shows RedisJSON (ReJSON), RediSearch, TimeSeries, Bloom and VectorSet all already loaded — Redis 8 bundles what used to be separate redis-stack modules into the base image. A GenericContainer of the plain image, wired up through spring-boot-testcontainers‘s @ServiceConnection, is enough for the whole test class:
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:8.8.3")).withExposedPorts(6379);
Download: CartJsonServiceTest.java.
What this post didn’t cover
- No JSON schema or indexing. RediSearch can index fields inside a JSON document for query-by-field across many carts; this post only ever addresses one document at a time by its key.
- No optimistic locking. Two concurrent
addItemcalls against the same cart both succeed server-side — RedisJSON’s own path operations are atomic per command — but nothing here coordinates a multi-step business rule across several commands. - No TTL. A real shopping cart usually expires; this demo’s carts live until explicitly deleted.
When this is the right tool
Reach for JsonOperations specifically when a document needs partial reads or partial updates — one field, one array append — without round-tripping the whole thing through your application. If every access to a value reads or writes it as a single opaque unit anyway, Spring Data Redis’s ordinary RedisTemplate/ValueOperations is simpler and needs none of the milestone-version override this post required.
| Need | What to use |
|---|---|
| Whole value as one opaque blob | ValueOperations on the stable spring-data-redis GA line |
| Read or write one field of a JSON document | JsonOperations‘ path(...) read/write (4.2 milestone) |
| Append to a nested array without reading the document first | json.array(key).path(...).append(item) |
| Query many documents by an indexed JSON field | RediSearch’s JSON indexing (not covered here) |
Get the code
This demo has no dedicated repository module — the source files are uploaded individually to this site’s media library:
- pom.xml
- Cart.java
- RedisJsonConfig.java
- RedisJsonDemoApplication.java
- CartJsonService.java
- CartJsonServiceTest.java
Further reading
- Spring Data Redis reference — Spring Data Redis documentation
- Redis JSON commands — JSON command reference
- Jackson 2 vs Jackson 3 — jackson-databind on GitHub
No Comments yet!