1. When DTO is not enough in PATCH
If you look at PATCH from the client side, everything seems simple: “I am only sending what I want to change.” But if you look from the server side, a tricky moment appears: in Java null is one value, while in JSON we have two different states: “the field is absent” and “the field is present, but equal to null.” For PATCH, these are fundamentally different intentions, especially for optional fields like description or assigneeName. And here the usual TaskPatchRequest as a record with a String description suddenly starts flattening the meaning: both the absence of the field and {"description":null} turn into description() == null. The server has to guess, and guessing is not a contract, it is a lottery where the main prize is a nighttime incident in production.
Suppose we want to support the following behavior for task fields:
- if the client sends "description": "..." — we change the description;
- if the client sends "description": null — we clear the description (set null);
- if the client does not send description — we leave the current value untouched.
With a regular DTO, the naive logic from the previous lecture looks like this:
public void applyPatch(Task current, TaskPatchRequest patch) {
// If the value is not null, update the field
// IMPORTANT: with this approach, it is impossible to "clear the field", because clearing is also null.
if (patch.description() != null) {
current.setDescription(patch.description());
}
}
And here is the problem: how do you “clear description” if clearing is also expressed as null? You cannot. The DTO does not know whether the field was present in JSON. So we need an additional source of information — not about the value, but about the fact that the field is present. That is exactly what JsonNode gives us: a request tree where we can ask “was the field there?” separately from “what value does it have?”
To avoid the feeling that we are somehow “betraying DTOs,” let us fix a very practical course rule: DTO remains the main way to design a contract, and JsonNode is a local magnifying glass/microscope that we turn on when we need to see additional details of the incoming JSON (structure, field presence, explicit null).
2. JsonNode as a JSON tree
When you use @RequestBody TaskPatchRequest, Spring MVC, through Jackson, deserializes JSON into a Java object. This is convenient, type-safe, and readable. But there is a price: JSON turns into “DTO field values,” and part of the contextual information is lost. JsonNode is another view of the same data: you do not get a “ready-made object,” but a tree of nodes where you can separately ask which keys actually arrived, what values they hold, and whether they are null. It is a bit like being given the recipe and ingredients instead of the finished dish: cooking is a little harder, but at least you know for sure whether there was meat in it at all or just a smoked flavor.
Do not confuse JsonNode with Map<String, Object>. Yes, both approaches are “dynamic,” but in feel and in capabilities they are different. JsonNode preserves JSON semantics clearly (object/array/value/null/missing), lets you check node type, and handles missing fields quite neatly. Map<String, Object> is more like a “bag with something in it”: it is easy to lose types there, get an unexpected LinkedHashMap instead of a nested DTO, and accidentally start doing an instanceof party that ends in tears.
A small comparison table (as a cheat sheet, not as a religion):
| Approach | What we get | Strengths | Weaknesses | Where it fits in the course |
|---|---|---|---|---|
| DTO | Typed object | Clear, safe, convenient for a contract | Field presence is lost; null is ambiguous | Main path for the API |
| JsonNode | JSON tree | We see absent vs null; we see structure; we can check type | More code is needed; easy to get carried away by dynamics | Local use: PATCH semantics, small dynamic fragments |
| Map<String, Object> | Dynamic container | Fast, flexible, convenient for a small “extra” block | Types are vague; easy to end up with instanceof | Only for small, explicitly limited dynamic blocks |
If we say it very briefly and in plain language: DTO is a proper contract, JsonNode is a magnifying glass, and Map<String, Object> is “a shopping bag without a receipt” (it looks like everything is there, but you only find out what exactly when you get home).
3. Important JsonNode methods
Almost all beginner problems with JsonNode come from expecting JSON tree behavior to match that of a “normal object,” but it is different. JsonNode has several methods that sound similar but behave differently. And if you choose the wrong one, you can get a NullPointerException even where it seemed like “but I already checked!” It is like String and StringBuilder: both are “about strings,” but if you use them for the wrong purpose, things get sad, and sometimes even slow.
Let us start with a small example: the difference between get(...) and path(...) is a classic worth learning once so you do not have to debug “why does it sometimes crash?” later.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper mapper = new ObjectMapper();
// Read JSON as a tree
JsonNode root = mapper.readTree("{\"title\":\"Refine API\"}");
// get(...) returns null when the key is missing
JsonNode v1 = root.get("missing"); // returns null
// path(...) returns a MissingNode when the key is missing (a safe "stub")
JsonNode v2 = root.path("missing"); // returns MissingNode (not null)
// Demonstrate the difference
System.out.println(v1 == null); // true
System.out.println(v2.isMissingNode()); // true
Pay attention to an important detail: root.get("missing") returns exactly null, which means any call to root.get("missing").isNull() will fail. path(...), on the other hand, returns a special stub node, MissingNode, and you can safely continue working with it.
Another useful pair is has(...) and hasNonNull(...). The first answers the question “is the key there?”, the second — “is the key there and the value not null?”. For PATCH, we more often need has(...), because we want to distinguish {"field":null} from {}.
import com.fasterxml.jackson.databind.JsonNode;
// root is a JsonNode, for example obtained through mapper.readTree(...)
boolean hasDescription = root.has("description"); // is the field present?
boolean hasNonNullDescription = root.hasNonNull("description"); // present and not null?
In PATCH semantics, “the field is present but equal to null” is often a separate command (for example, clear the value). Therefore hasNonNull(...) can even be harmful: it hides the explicit null from us.
A small cheat sheet for the most useful methods in the context of PATCH:
| Method | What question it answers | Behavior when absent | Useful for PATCH? |
|---|---|---|---|
| has("x") | “Was key x present?” | false | Yes, this is the main one |
| get("x") | “Give me node x” | null | Use with care |
| path("x") | “Give me node x, but safely” | MissingNode | Yes, convenient |
| isNull() | “Is the value an explicit JSON null?” | — | Yes |
| asText() | “Read as a string” | on MissingNode gives "" | Use with care, check first |
The last point is especially important: asText(...) on MissingNode may return an empty string, and if you did not check has(...), you will suddenly turn “field absent” into “empty string.” This is one of the most insidious logical bugs: the application works, but does the wrong thing.
4. JsonNode in Task Tracker API for PATCH
The healthiest way to introduce JsonNode into our project is to use it locally at the boundary where we read the HTTP body, exactly where we need to distinguish absent from explicit null. At the same time, the contract does not become dynamic: further on, we still want to work with a typed TaskPatchRequest, so that we do not have to manually parse dates, enums, and lists. The idea is simple: the tree (JsonNode) answers the question “was the field present?”, and the DTO (TaskPatchRequest) answers the question “what value and of what type?”. It is like two employees working together: one checks whether the package arrived at all, and the second unpacks it and looks inside.
Let us start with the controller. For demonstration, we will accept JsonNode as the request body. Externally, the contract still remains a JSON object with known fields; we just do not bind it to a DTO immediately in Java.
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.mapper.TaskMapper;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
public TaskController(TaskService taskService, TaskMapper taskMapper) {
this.taskService = taskService;
this.taskMapper = taskMapper;
}
@PatchMapping("/{taskId}")
public TaskDetailsResponse patchTask(
@PathVariable String taskId,
@RequestBody JsonNode patchJson // accept a tree so we can see whether the field was present or not
) {
// The controller remains thin: we delegate business logic to the service
Task task = taskService.patch(taskId, patchJson);
// Map the domain model to the response DTO
return taskMapper.toDetailsResponse(task);
}
}
Notice that the controller remains thin: it does not do merge logic and does not analyze JSON. It only accepts input and delegates further. The “dirty work” is in the service.
If you push JsonNode deep into the service layer, the typed PATCH contract gets blurred again. That is why it is useful to keep the tree close to the web-layer boundary: it is responsible for field presence, and then the data is better translated quickly into a DTO or another typed carrier.
Now the service. Inside the service we want typed values, so we convert the tree into TaskPatchRequest. In Jackson, this is conveniently done through treeToValue(...). Important: we take the ObjectMapper from Spring (inject it), rather than creating a new one “however it turns out,” otherwise you will lose the shared configuration (especially for dates).
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class TaskServiceImpl {
private final ObjectMapper objectMapper;
public TaskServiceImpl(ObjectMapper objectMapper) {
// Important: inject ObjectMapper from Spring so we do not lose the shared configuration
this.objectMapper = objectMapper;
}
public TaskPatchRequest readPatch(JsonNode patchJson) throws Exception {
// Convert the JSON tree into a typed DTO
// (enum values, dates, etc. will be handled by Jackson rules)
return objectMapper.treeToValue(patchJson, TaskPatchRequest.class);
}
}
Here the code is still a “half-finished product,” but the logic is clear: TaskPatchRequest provides proper types for dates, enums, and lists, while JsonNode preserves field presence. These two roles do not compete; they complement each other.
Now the most important part — applying the patch to the current state. We want to distinguish between “the field is absent” and “the field is present and equal to null.” The simplest and clearest approach looks like this: check patchJson.has("description"), and if yes, apply the value from the DTO (including null, which now means “clear”).
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.domain.model.Task;
import com.fasterxml.jackson.databind.JsonNode;
public class TaskPatchApplier {
public void apply(Task current, TaskPatchRequest dto, JsonNode json) {
// title: here the fact that the field is present matters.
// If the field arrived but the value is null, that is no longer "leave it alone" but a contract violation.
if (json.has("title")) {
if (dto.title() == null) {
throw new IllegalArgumentException("title cannot be null");
}
current.setTitle(dto.title());
}
// description: distinguish "field absent" and "field present and null"
if (json.has("description")) {
// dto.description() may be null -> this is a separate "clear" command
current.setDescription(dto.description());
}
// assigneeName: same PATCH logic as for description
if (json.has("assigneeName")) {
current.setAssigneeName(dto.assigneeName());
}
}
}
Here the separation of roles is clearly visible: json.has("title") answers the question “was the field present?”, while dto.title() answers “what value came in and what type is it?”.
For fields where null should not mean a separate command, JsonNode is still needed just as much: it allows us to distinguish “the field was not touched at all” from “the field was sent as null and violated the contract.”
// priority: the same logic as for title.
// We need to distinguish "the field was not touched" from "the field was sent as null and violated the contract".
if (json.has("priority")) {
if (dto.priority() == null) {
throw new IllegalArgumentException("priority cannot be null");
}
current.setPriority(dto.priority());
}
At this point, it is important not to mix two different tasks. JsonNode is needed for PATCH semantics: it helps us see whether a field was in the JSON and whether an explicit null came in. TypeReference solves a different problem: how to preserve a generic type when we read JSON containers directly through ObjectMapper.
5. TypeReference and type erasure
At some point you will start reading or writing JSON not only through Spring MVC (where @RequestBody does everything), but also directly through ObjectMapper: in small utilities, in experiments, in local JSON investigations, or sometimes just to understand what is going on. That is where you will meet TypeReference. It looks like a strange anonymous class, and a beginner’s first reaction is often: “why this wizardry, can’t I just write List<TaskPatchRequest>.class?” Spoiler: you cannot, because Java.
The problem is called “type erasure” (type erasure). At compile time, List<TaskPatchRequest> and List<CommentCreateRequest> both become just List for the JVM. The information about the type parameter (TaskPatchRequest) is not stored at runtime the way we would like. Jackson, when reading JSON, also lives at runtime. If you ask it to read “just a List,” it will honestly read a list, but the elements will be as generic as possible (usually LinkedHashMap for objects). And this is not a Jackson bug, it is an honest answer to an honest question: “you asked for a list of something — I gave you one.”
TypeReference is a way to hint to Jackson a more exact type with generic parameters. In essence, you create an object that carries information like: “this is not just a List, this is a List<TaskPatchRequest>.” It looks like magic, but in reality it is just a workaround around the limitations of generics in Java.
6. TypeReference in practice
Let us start with a simple but very illustrative example: a JSON array of patch requests. Yes, in a real API we will not patch like this (we are not doing batch operations today), but as a teaching example it perfectly shows the problem.
First let us try the “wrong” way — read JSON as List.class. Jackson does not know what the elements are, so it reads them as Map.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
ObjectMapper mapper = new ObjectMapper();
String json = "[{\"title\":\"A\"},{\"title\":\"B\"}]";
// Ask Jackson to read "just a list" without information about element type
List
raw = mapper.readValue(json, List.class);
// Therefore the elements will be as generic as possible (usually Map)
System.out.println(raw.get(0).getClass()); // class java.util.LinkedHashMap
It seems to work, but now instead of TaskPatchRequest you have LinkedHashMap. Of course, you can manually map each element, but that is like washing dishes with a toothbrush: technically possible, but why.
Now let us do it correctly — use TypeReference<List<TaskPatchRequest>>. Notice that the JSON itself is the same.
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
ObjectMapper mapper = new ObjectMapper();
String json = """
[
{ "title": "A" },
{ "title": "B" }
]
""";
// Pass TypeReference to preserve the generic type of list elements
List<TaskPatchRequest> items = mapper.readValue(json, new TypeReference<>() {});
// Now the elements are DTOs, not Maps
System.out.println(items.get(0).title()); // A
And now Jackson creates a list of exactly our DTOs, with proper fields and types. This is especially important when the elements contain enums, dates, and nested structures: without TypeReference you will get maps, not an object model.
Now the second scenario: a small dynamic “extra” block. Here we do not need a DTO for every field, because the point of the block is flexibility, but we still want to read it carefully and type-safely at least at the container level.
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
ObjectMapper mapper = new ObjectMapper();
String json = "{\"source\":\"import\",\"attempt\":2}";
// Here we acknowledge the "dynamic" nature: read as Map, but keep the container type fixed
Map<String, Object> extra = mapper.readValue(json, new TypeReference<>() {});
System.out.println(extra.get("source")); // import
Yes, inside Object it will still remain Object, and numbers may become Integer, Long, or Double — that is normal for a dynamic block. The key point is that you honestly admit: “this part of the document is dynamic,” and do not try to turn it into a “universal DTO for everything.”
7. Mixed JSON: DTO + dynamic data
The nicest part of this topic is that Jackson supports “mix-and-match” very well: you can take one JSON document, read the typed part into a DTO, and read a small dynamic piece separately. This lets you keep the contract strict where it should be strict, while not breaking your brain where “perfect typing” is simply not worth the effort. The main thing is to keep the boundary: the dynamic part should be small and explicitly marked, otherwise the project gradually starts turning into “a server that accepts any JSON and pretends everything is under control.”
Suppose we have a hypothetical document (just for demonstrating the technique), where there is a known object taskPatch and an additional dynamic extra:
{
"taskPatch": { "assigneeName": null, "priority": "HIGH" },
"extra": { "source": "import", "attempt": 2 }
}
In code, it can look like this: first we read the tree, then we translate the known part into TaskPatchRequest, and the dynamic part into Map<String, Object>.
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
ObjectMapper mapper = new ObjectMapper();
String json = """
{
"taskPatch": { "assigneeName": null, "priority": "HIGH" },
"extra": { "source": "import", "attempt": 2 }
}
""";
// 1) Read the original document as a tree so we can "cut out" pieces
JsonNode root = mapper.readTree(json);
// 2) Read the typed part into a DTO (we get enum/dates/format validation according to Jackson rules)
TaskPatchRequest patch = mapper.treeToValue(root.get("taskPatch"), TaskPatchRequest.class);
// 3) Read the dynamic part into a Map: the shape is flexible, but the dynamic boundary is explicit
Map<String, Object> extra = mapper.convertValue(root.get("extra"), new TypeReference<>() {});
Pay attention to two things. First, the typed part (patch) remains typed: priority becomes TaskPriority, and if the client sends a bogus enum, Jackson will notice it (we will discuss how exactly we present the error externally in the module on error handling, but today we care about the division of responsibilities itself). Second, extra remains flexible and constrained: it does not dictate the shape of the whole model and does not force us to abandon DTOs.
In the context of the Task Tracker API, you can keep such a technique as an “engineering tool,” not as a mandatory part of the contract. In other words, we do not have to add extra to the public PATCH right now, but we already know how to do it if a real reason appears in the future (for example, a migration client, an import, an integration). And that is exactly “controlled flexibility,” not chaos.
For the actual Task PATCH, the working path here is still the same: at the web-layer boundary, preserve field presence through JsonNode, read values into a typed DTO, and apply per-field rules explicitly. TypeReference simply stays nearby as a Jackson tool for generic containers and small dynamic fragments.
8. Typical mistakes when working with JsonNode and TypeReference
Mistake #1: use JsonNode everywhere “just in case,” because it seems easier.
At first this really does seem easier: no need to create DTOs, no need to think about types, you can just write root.get("x").asText(). But after a week you suddenly realize that your project has no contract, no proper types, and any error turns into guessing. Keep JsonNode as a point tool: to determine field presence, to see explicit null, to read a small dynamic block. Everything else should remain DTO.
Mistake #2: confuse get(...) and path(...), and then catch a NullPointerException.
root.get("missing") returns null, and if you forget the check, it will crash. In teaching examples this is caught quickly, but in real code it sometimes “only fails on one strange request” and you spend a long time looking for the cause. If you are walking through the tree without a guarantee that the field exists, the habit of using path(...) instead of get(...) usually makes the code more resilient and life calmer.
Mistake #3: check only the value, but do not check field presence.
This is the main PATCH trap: you write if (dto.description() == null), and think this means “clear it,” but in reality it may mean “the field was not sent at all.” As a result, you either cannot clear the field or you clear it by accident. For PATCH, first determine “was the field there?”, and only then “what value?”
Mistake #4: try to read List<MyDto> without TypeReference, and then be surprised by LinkedHashMap.
Jackson is not a telepath. If you tell it “read a List,” it will read a list of “some objects” and return a Map. This is not “bad Jackson,” it is “too generic a request.” As soon as you read generic structures directly through ObjectMapper, keep in mind: TypeReference is a normal necessity, not a strange whim of the library.
Mistake #5: turn a dynamic Map<String, Object> into the main public contract.
Usually this starts as a small “extra” block “for experiments,” and ends as an endpoint that accepts “anything,” because “it is more convenient for clients.” Then manual validation begins, manual errors, manual conversions, and the code starts to look like a tiny interpreter for the JSON language. If you need dynamics, keep it to a small fragment, keep it in one place, and do not be afraid to say “no, this field is typed in our API.”
GO TO FULL VERSION