1. PATCH without a contract: guesswork
We already have all the pieces for this task: PATCH works on top of the current Task, a field can be absent, explicit null, or have a new value, the patch DTO must be narrow, and a plain DTO on its own does not see whether a field is present. Now we need to put all of that into one working agreement for /api/v1/tasks/{taskId}. Otherwise, in one place null will be silently ignored, in another it will clear a value, and in a third server-managed fields will unexpectedly slip through the body.
So from here on, we establish one working approach: which fields are patchable, what absence of a field means, what null means, what a new value means, how we reject unknown fields, and what carrier reaches the service. If there is no single answer to these questions, PATCH quickly turns into guesswork for both the client and the server.
2. PATCH /api/v1/tasks/{taskId}
Before talking about JSON examples, it is useful to nail down the contract itself for a second. We have one specific endpoint, and it should be readable, predictable, and boring (in a good way). A boring API is an API that cannot surprise you on Friday evening.
In the canonical version of the Task Tracker API, we fix this basic frame:
- URI: /api/v1/tasks/{taskId}
- Method: PATCH
- Request body: application/json
- Response: 200 OK + updated TaskDetailsResponse (we do not return “just OK, trust me,” because it is usually convenient for the client to see the final resource state immediately)
- taskId is the resource identifier in the path, not part of the JSON body (and certainly not “can I change it?”).
Here PATCH is neither a JSON Patch nor a JSON Merge Patch document. We use a regular JSON object with predefined patchable fields and per-field rules for absent / null / value.
And one more boundary: we do not hide status transitions and other business operations inside a generic PATCH so that resource rules are not bypassed.
3. Patchable fields: a list without surprises
A contract needs a fence. Otherwise the API, like a cat, will decide on the boundaries for itself.
For Task, we fix the patchable set like this:
- title
- description
- assigneeName
- dueDate
- priority
- tags
And we deliberately do not include: id, createdAt, updatedAt, status, and other server-managed or business-sensitive parts of the state.
In code, it is convenient to keep this list as an explicit constant. This is not “magic” but a document that the compiler checks better than our memory does.
import java.util.Set;
public final class TaskPatchContract {
// Explicit whitelist of fields that are allowed to be patched at all.
// Important: these are JSON names, not setter/field names in the domain model.
public static final Set<String> ALLOWED_FIELDS = Set.of(
"title", "description", "assigneeName", "dueDate", "priority", "tags"
);
// Utility class: no instances needed.
private TaskPatchContract() {}
}
Note that these are JSON field names. We are fixing the external contract, not the internal shape of the domain model.
4. Semantics of absent/value/null
Now we establish per-field semantics. The universal formula if (field != null) looks nice only until the first field that needs to be cleared, and until the first required field that must not accept null at all.
| Field | If the field is missing from JSON | If the field exists and the value is not null | If the field exists and the value is null |
|---|---|---|---|
| title | do not change | replace with new value | error (you cannot “erase” a required field) |
| description | do not change | replace | clear (description = null) |
| assigneeName | do not change | replace | clear (assigneeName = null) |
| dueDate | do not change | replace | clear (dueDate = null) |
| priority | do not change | replace | error (priority is not “emptiness” but an enum value) |
| tags | do not change | full replacement of the list | error (clearing is only through []) |
The key point: we consciously choose simple rules, even if they are not the most “flexible.” Flexibility in patch semantics is like freedom in Vim configuration: if you are just learning, freedom quickly turns into suffering. Our rules must be understandable to a beginner and reproducible in tests.
5. Allowed payloads
After such a table, the payload is readable without guessing. Here are a few reference variants.
Example: “change only the title”:
{
"title": "Refine task endpoint"
}
Example: “clear the assignee” — and this is a separate meaning, not an accidental loss of data:
{
"assigneeName": null
}
Example: “replace tags completely” — not “add one,” but replace the list as a value:
{
"tags": ["api", "rest"]
}
Example: “clear all tags” — via an empty array, not null:
{
"tags": []
}
And finally an example of “change nothing”:
{}
Formally, this is a no-op: the fields are absent, so we do not touch anything. In practice, such a request usually means the client did not build a patch at all, but the contract itself still remains predictable.
6. Restrictions and unknown fields
The contract is also useful because it rejects everything extra: server-managed and unknown fields.
Server-managed fields
Payloads like these should be considered invalid:
{
"id": "task-1",
"createdAt": "2026-03-18T10:00:00Z"
}
The reason is simple: id, createdAt, updatedAt are part of the server's internal resource management. As soon as the client gets the right to change them, you break resource history, create hard-to-explain bugs, and get that “task creator from the future.”
Unknown fields
Another class of forbidden requests is a “field that is not in the contract.” For example:
{
"title": "Refine task endpoint",
"unexpectedField": true
}
In PATCH, this is especially important because an incomplete document tempts the server to “stay silent and ignore it.” But if the client wrote assigneName instead of assigneeName, silent ignore simply hides the error. The field will not be updated, and the investigation into “why it does not work” will only start after the request.
So it is better to choose one rule for the entire API and stick to it. In our version, unknown fields are an error.
One unified rule for unknown fields
It is important not to make the typical mistake: “we are strict in PATCH, but lenient in POST,” or vice versa. The client does not care about your internal logic — it needs a predictable external contract. Therefore the rule must be the same.
If we choose strict input, it is convenient to pin this down in configuration:
spring:
jackson:
deserialization:
# If the client sends a field that does not exist in the DTO/contract, it is better to fail immediately
# than to "silently ignore" it and then investigate typos.
fail-on-unknown-properties: true
This is not “fine-tuning ObjectMapper for PATCH” but contract discipline. We are simply saying: “If the client sent a field that is not in the agreement, we will not pretend nothing happened.”
And even in strict mode, the whitelist is still useful: “the field is known to Jackson” and “the field is allowed to be patched” are not the same thing. Today we are fixing exactly the PATCH list.
7. Reading PATCH: absent vs null
Now for the most practical part: how do we tell in code “the field is missing” from “the field is explicitly null” if a normal TaskPatchRequest during deserialization will give null in both cases? This is where JsonNode becomes useful as an “observation layer.”
flowchart TD
A["PATCH /api/v1/tasks/{taskId}"] --> B["Read body as JsonNode"]
B --> C{"Any unknown fields?"}
C -->|yes| X["400: invalid patch field"]
C -->|no| D["Convert JsonNode -> TaskPatchRequest"]
D --> E["Apply patch by absent/value/null rules"]
E --> F["200 OK + TaskDetailsResponse"]
Notice: JsonNode is needed here not to “make the contract dynamic,” but to preserve meaning. The contract is still typed and narrow; we just do not lose information about field presence.
The DTO remains: TaskPatchRequest
We still need a patch DTO because it fixes the field set and types.
import java.time.LocalDate;
import java.util.List;
public record TaskPatchRequest(
// null here does not always mean "do not change anything": the meaning of null is defined by the contract.
String title,
String description,
String assigneeName,
LocalDate dueDate,
TaskPriority priority,
List<String> tags
) {}
The external contract remains: “JSON → patch model.” We just add information about which fields were actually present.
Extract field presence and validate the whitelist
First we read JsonNode and collect presentFields. At the same time, we check that all fields are in the whitelist; otherwise we stop immediately.
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public static Set<String> readPresentFields(JsonNode root, Set<String> allowed) {
// Here we distinguish "the field was not there at all" from "the field was there, but it was null".
// At the same time, we perform a whitelist check so PATCH does not turn into "accept everything."
Set<String> present = new HashSet<>();
Iterator<String> names = root.fieldNames();
while (names.hasNext()) {
String name = names.next();
// Unknown fields are a contract error (usually a typo or an attempt to patch a forbidden field).
if (!allowed.contains(name)) throw new IllegalArgumentException("Unknown patch field: " + name);
present.add(name);
}
return present;
}
This check makes the contract “narrow” in practice. We do not let PATCH turn into a universal input for every case.
Convert JsonNode to TaskPatchRequest
After the whitelist check, we convert the tree into a DTO. If the JSON is malformed (for example, dueDate in the wrong format or priority not from the enum), Jackson will still fail — and that is fine: this is not a “business conflict,” it is just invalid input.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public static TaskPatchRequest toPatchRequest(ObjectMapper mapper, JsonNode root) {
try {
// treeToValue preserves the typed DTO, but we keep the field-presence fact separately.
return mapper.treeToValue(root, TaskPatchRequest.class);
} catch (Exception e) {
// Here it is important to return a clear "payload is broken" error, rather than masking it as business validation.
throw new IllegalArgumentException("Malformed PATCH payload", e);
}
}
We give the service and the domain model not a JsonNode, but a normal Java model.
Wrap everything in one “patch command” object
To avoid carrying around “DTO plus set of fields” throughout the project, you can wrap it into one small object.
import java.util.Set;
public record TaskPatchCommand(TaskPatchRequest request, Set<String> presentFields) {
// Utility for readable code: ask "was the field in JSON?" instead of "value != null?".
public boolean has(String field) {
return presentFields.contains(field);
}
}
This is a handy “carrier of meaning”: now the service can ask not “is the value null?”, but “was the field in JSON at all?”.
8. Controller and applying the rules
The final step is to show how this contract comes alive in the code of Task Tracker API. We do not need a huge controller; we need a thin method that reads input, builds a patch command, and delegates to the service.
Controller: read JsonNode, build TaskPatchCommand
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
private final ObjectMapper objectMapper;
private final TaskService taskService;
public TaskController(ObjectMapper objectMapper, TaskService taskService) {
this.objectMapper = objectMapper;
this.taskService = taskService;
}
@PatchMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse patchTask(@PathVariable String taskId, @RequestBody JsonNode body) {
// 1) First, find out which fields are actually present in JSON, and validate the whitelist right away.
var present = readPresentFields(body, TaskPatchContract.ALLOWED_FIELDS);
// 2) Then convert JSON into a typed DTO (types/formats will be validated by Jackson).
var request = toPatchRequest(objectMapper, body);
// 3) Pass a "command" to the service, not JsonNode: the service works by contract, not by tree.
return taskService.patch(taskId, new TaskPatchCommand(request, present));
}
}
Yes, JsonNode is directly in the controller signature. That is acceptable because this is still the web layer, and this is exactly where we work with the “shape of the incoming JSON.” We do not pass the tree to the service; we pass TaskPatchCommand to the service.
And TaskDetailsResponse can be obtained through a mapper; I shortened the example to the essentials. In a real project, of course, you would return the response DTO through taskMapper.
Applying patch rules: “explicitly, by field, by agreement”
Now the most important part: the service applies the patch not “naively,” but according to the contract.
import java.util.List;
public Task applyPatch(Task current, TaskPatchCommand patch) {
// Principle: first check that the field was in JSON (absent vs present),
// and only then look at the value (value vs null) and apply the contract rule.
if (patch.has("title")) {
if (patch.request().title() == null) throw new IllegalArgumentException("title cannot be null");
current.setTitle(patch.request().title());
}
if (patch.has("description")) {
current.setDescription(patch.request().description()); // null = clear (per contract)
}
if (patch.has("tags")) {
List<String> tags = patch.request().tags();
// For tags, we forbid null so that "clearing" happens only through [] (easier to test and explain).
if (tags == null) throw new IllegalArgumentException("tags must be an array");
current.setTags(List.copyOf(tags)); // full list replacement, no "smart" merge
}
return current;
}
The important idea: we do not check “!= null → update.” We check “has(field) → apply the rule,” and only then look at the value. That is the discipline we spent the whole day discussing when we talked about absent vs null.
9. Typical mistakes in a PATCH contract
Mistake #1: “PATCH is just a trimmed-down PUT, so we can accept TaskDetailsResponse as input.”
This looks convenient, but it turns the contract into a mess: the client gets the right to send fields it should not control (server-managed, derived, service fields). Even if you “ignore them later,” the very fact that the fields are in the contract already creates expectations for the client. It is better to keep a separate TaskPatchRequest and not mix input and output.
Mistake #2: “If the field is null, then do not change it” (or vice versa “if the field is null, then clear it”) — one rule for everything.
Universal rules in patch semantics almost always break the contract. For title, null is an error; for assigneeName, null is a clear command; and for tags, we explicitly forbid null so that clearing is done through []. If you reduce everything to one interpretation, you will either lose the ability to clear values or start erasing required fields.
Mistake #3: Ignore unknown fields “to be friendly.”
This friendliness usually ends with the client sending assigneName for a month, nothing happens, and everyone thinks that “PATCH is broken.” In a real API, strict mode often saves time rather than wasting it: it makes mistakes visible and fast. If you choose tolerant input, do it consciously and consistently across the entire API.
Mistake #4: Not fixing collection semantics and trying to “smartly” merge tags.
As soon as you say “you can send one tag and it will be added,” new questions appear: what about duplicates, how do we remove a tag, how do we ensure idempotence, how do we distinguish “replace” from “add.” In educational and most commercial APIs, it is simpler and more reliable to agree: “if the tags field is present — replace the list completely.” Period.
Mistake #5: Trying to support patch for id, createdAt, updatedAt “just in case.”
At that point PATCH starts to behave like an “admin panel hole.” Even if your project has no security, you are still learning how to design an external contract. Server-managed fields must remain server-managed, otherwise you destroy the very idea that “the server manages the resource.”
GO TO FULL VERSION