1. The role of PATCH alongside PUT
When you first hear “partial update,” your brain makes a very human conclusion: “Ah, so it is PUT, only I will send not everything.” And that is exactly where the most expensive bugs begin, because the server and the client start interpreting missing fields differently. PATCH exists so that you can explicitly and legitimately say: “I am changing only what I sent,” without pretending that the client submitted a complete snapshot of the task’s editable state.
Think of PUT as replacing an entire document: you take a sheet of paper, rewrite it from scratch, and hand over a new version. PATCH, on the other hand, is a careful correction with a marker: “fix the title here, and leave everything else alone.” In commercial development, PATCH is very helpful for the UI: the user changes priority, and the frontend does not have to assemble “the whole task,” risking data loss or sending stale state.
It is important that in our course PATCH is not “magic for updating anything,” but an explicit contract. We define in advance which fields can be patched, how missing fields are interpreted, what null means (if we allow it at all), and which errors are returned for invalid input. That is how we avoid sliding into “well, it seems to work... until the first mobile client shows up.”
2. PATCH as a contract
PATCH has a tricky property: unlike PUT, it often looks “simple” in code (a few if statements), but “expensive” in agreement. If you did not define the rules, two different clients may expect opposite behavior, and both will be sincerely convinced they are right. So we mentally start not with the @PatchMapping annotation, but with the API promise: what changes are possible and how they are applied.
In the Task Tracker API, we will interpret PATCH like this: the client sends JSON with a subset of allowed fields, and the server applies only the changes that are actually present in the request body and permitted by the rules. Everything that did not arrive stays as it was. We also do not expose the internal model to the client: PATCH must not suddenly allow changing id, createdAt, updatedAt, or any other “internal” stuff — otherwise we will create over-posting for ourselves in the style of “the user updated a task and accidentally assigned themselves a createdAt value in 1970.”
To reinforce the difference, it is useful to compare PUT and PATCH not by slogan, but by concrete parameters:
| Parameter | |
|
|---|---|---|
| Operation idea | Full replacement of the editable part | Partial change of individual fields |
| Expected payload | “Full set of editable fields” | “Only what I want to change” |
| Missing field | Usually means “reset/replace” (or the contract is a lie) | Means “do not change” |
| DTO | TaskPutRequest | TaskPatchRequest |
| Validatability | We check the whole set of required fields | We check only the fields that were sent |
| Predictability | High, if PUT is honest | High, if PATCH rules are fixed |
And one more important point: PATCH does not have to be “non-idempotent.” In many APIs, PATCH is made idempotent at the effect level if it describes “set values” rather than “add 1” or “append an item to a list.” In our project, we will stick to “set values,” because it is easier to explain, easier to test, and less surprising for the client.
3. TaskPatchRequest DTO
The most common beginner mistake is to use for PATCH either the same DTO as for PUT, or even a Map<String, Object>. In the first case, you blur the meaning of PUT (full replacement turns into “whatever arrived, we updated”), and in the second, you hand the contract over to “dynamic JSON,” where mistakes become unexpected and documentation turns into reading tea leaves. Our course intentionally chooses a third path: a typed DTO, but specifically for partial updates.
The idea behind TaskPatchRequest is simple: every field in the DTO is an “allowed handle” the client can pull. Fields in such a DTO are usually nullable, because “did not send the field” and “sent the field” are encoded through null vs non-null. Yes, there is a philosophical question here about “missing field” and “explicit null,” but in this lecture we keep the contract simple and clear: null means “do not change”. This rule makes behavior predictable and does not force us to introduce separate tri-state constructs (present/absent/null) already at the Junior stage.
This simplification is intentional. In this PATCH contract, we work only with fields for which the rule “non-null value arrived — apply it, did not arrive — leave it alone” is enough, so the current TaskPatchRequest is limited to title/status/priority. You cannot clear description, dueDate, or assigneeName through it: Jackson does not distinguish here between “field is absent” and “field came as null” in a way that lets us honestly encode both meanings in one DTO. When you need an actual clear scenario for a nullable field, it is more honest to use PUT with a full replacement or design a separate tri-state model.
A minimal model for our example might look like this:
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import jakarta.validation.constraints.Size;
/**
* DTO for partial task updates via PATCH.
* IMPORTANT: null here means "do not change the field".
*/
public record TaskPatchRequest(
/* New title (if null, leave it alone). @Size is checked only if a value is provided. */
@Size(min = 3, max = 120) String title,
/* New status (if null, leave it alone). */
TaskStatus status,
/* New priority (if null, leave it alone). */
TaskPriority priority
) {}
Notice a nice Bean Validation trick: @Size does not complain about null; it checks the length only if a value actually exists. So for PATCH this is almost the ideal annotation: if the field did not arrive, it is null and considered valid; if it did arrive, we validate it. This is one of those rare cases where nullable fields work for us, not against us.
4. The PATCH controller
At the HTTP level, there is no special magic here: Spring MVC reads the body into TaskPatchRequest through HttpMessageConverter and Jackson, validation checks only the fields that actually arrived, and the controller only needs to accept taskId and delegate to the service. So the PATCH controller should also remain thin.
The controller method for PATCH looks very similar to PUT, but the meaning is different:
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
@PatchMapping("/{taskId}")
public ResponseEntity<TaskDetailsResponse> patch(
// We take the identifier from the URL, not from the request body
@PathVariable String taskId,
// In PATCH, we validate only what actually arrived (for example, @Size on title)
@Valid @RequestBody TaskPatchRequest request) {
// Delegate business logic to the service: the controller stays "thin"
TaskDetailsResponse updated = taskWriteService.patch(taskId, request);
// Return the updated task representation
return ResponseEntity.ok(updated);
}
And let us add a small .http example so PATCH is not left in a vacuum:
### Patch title (change only title, leave everything else alone)
PATCH http://localhost:8080/api/v1/tasks/9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111
Content-Type: application/json
{
"title": "Rename task"
}
Here the client changes only title. It does not need to know the current priority, does not have to repeat status, and does not have to “keep all fields in mind.” That is the value of PATCH for client code.
5. The PATCH service
In the service, PATCH becomes real: we take the current task, apply only the incoming changes, and save the result. Here ordinary if statements are better than any magic: they immediately show which fields are patched and under what rules.
A typical method looks like this:
import java.time.Instant;
public TaskDetailsResponse patch(String taskId, TaskPatchRequest request) {
// 1) Fetch the current task or fail with 404
Task task = getExistingTask(taskId);
// 2) Apply only the changes that actually arrived in PATCH
if (request.title() != null) {
task.setTitle(request.title()); // update title only if it was sent
}
if (request.status() != null) {
task.setStatus(request.status()); // update status only if it was sent
}
if (request.priority() != null) {
task.setPriority(request.priority()); // update priority only if it was sent
}
// 3) Server-managed field: set updatedAt on the server
task.setUpdatedAt(Instant.now());
// 4) Save and map to response DTO
return taskMapper.toDetailsResponse(taskRepository.save(task));
}
Notice: we do not read taskId from the body (it is not there at all), we do not touch createdAt, and we set updatedAt ourselves. This is a good example of the boundary where “the client controls only what it is allowed to control.”
And the helper method “get the task or throw 404” makes behavior consistent and removes duplication:
import java.util.Optional;
private Task getExistingTask(String taskId) {
// Single point of behavior for "task not found" -> domain exception -> 404
return taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
As a result, if the task does not exist, the service throws a domain exception, and the global @ControllerAdvice turns it into your ProblemDetail response. You do not write try/catch in the controller, you do not assemble different error formats “depending on the mood” — everything goes through a single contract.
A small flow diagram helps “see” PATCH as part of the overall system:
flowchart TD
A["HTTP PATCH /api/v1/tasks/{taskId}"] --> B["TaskWriteController.patch"]
B --> C["Bean Validation @Valid"]
C --> D["TaskWriteService.patch"]
D --> E["TaskRepository.findById/save"]
D --> F["TaskMapper -> TaskDetailsResponse"]
F --> G["HTTP 200 OK + JSON body"]
6. PATCH and validation
The most common naive thought is: “Since PATCH is partial, validation can be turned off.” In practice, that turns the API into a “trash chute,” where a title with two characters, a priority as the string "SUPER_HIGH", and then you wonder why the frontend crashes. The correct thought is different: validation stays, it just becomes “conditional” — we check only the fields that are actually involved in the change.
That is exactly why in TaskPatchRequest we put @Size(min=3, max=120) on title, but not @NotBlank. If title did not arrive, it is null, @Size says nothing, and that is valid. If title did arrive, then its length must be within the range. This is a clean way to achieve partial validation without groups and without complex mechanisms.
Now the second important point: an empty PATCH like {}. Formally, this is valid JSON, Spring will read it, TaskPatchRequest will have all fields set to null, and if you do nothing, you get an “update with no changes.” Sometimes that is acceptable as a no-op, but in a learning project it is often more useful to tell the client honestly: “You sent a request with no meaning.” And that is input validation, meaning 400 Bad Request, not 409.
The simplest way (not the most “theoretically pure,” but very clear) is to check in the service:
// If the client did not send any field, PATCH is pointless
if (request.title() == null && request.status() == null && request.priority() == null) {
throw new InvalidInputException("Patch request must contain at least one field");
}
Then your global error handler will already wrap this into a ProblemDetail with the correct code and status.
By the way, since we are talking about ProblemDetail: Spring MVC supports the RFC problem details format and can return errors in application/problem+json; this is enabled with spring.mvc.problemdetails.enabled=true. We already chose this path in the project earlier, so PATCH simply “fits into” the existing error system instead of creating a separate “PATCH error” format.
7. Server-managed fields and “cheat codes”
Sometimes a developer thinks: “What is the harm if the client sends updatedAt? We will just update it.” And then that same developer spends a day figuring out why sorting by updatedAt became strange and clients started “rolling back” time. Server-managed fields are controlled by the server not out of spite, but because they are part of the system’s internal integrity.
For Task in our project, server-managed fields include at least id, createdAt, and updatedAt. The client must not send them in POST, PUT, or PATCH. The correct protection is not “if (body.id != null) complain”, but the model itself: in TaskPatchRequest there are simply no such fields, so the client physically cannot send them as part of the contract.
A contrasting anti-example (how not to do it) looks roughly like this:
// Anti-example: such fields must not be exposed in a PATCH DTO
public record BadTaskPatchRequest(
String id,
String title,
String updatedAt
) {}
Here the client can “patch” what it should not. Even if you later try to filter this out, the very presence of such fields in a public DTO already blurs the contract: the client starts believing that “this is allowed,” and you turn into support for your own API.
8. End-to-end PATCH example
So that PATCH does not remain abstract, let us run a mini scenario through the eyes of the client.
Suppose we have a task:
{
"id": "9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111",
"title": "Write a README draft",
"status": "TODO",
"priority": "MEDIUM"
}
The user in the UI decides: “No, this is critical.” The client sends a request:
### Patch priority (change only priority)
PATCH /api/v1/tasks/9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111
Content-Type: application/json
{ "priority": "CRITICAL" }
Spring MVC reads the body, converts JSON into TaskPatchRequest (infrastructure does this via HttpMessageConverter), and you get an object where title == null, status == null, priority == CRITICAL. The service applies only priority, updates updatedAt, saves the task, and returns TaskDetailsResponse. This is exactly the scenario the current simplified PATCH handles well: change one non-null field, and leave everything else as is. Clearing a nullable field is not included here.
We make the response simple and predictable:
### 200 OK: return the current task state
200 OK
Content-Type: application/json
{
"id": "9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111",
"title": "Write a README draft",
"status": "TODO",
"priority": "CRITICAL"
}
If the client sends "priority": "SUPER_HIGH", then this is not a “business error,” but an input error (an invalid enum), and it should be returned in the unified application/problem+json format. As a platform, Spring supports problem details and lets you centralize such errors through @ControllerAdvice. The important idea: PATCH does not create new error-handling rules — it uses the same ones that already work for POST and PUT.
9. Common mistakes when implementing PATCH
Mistake #1: making PATCH a “short PUT” and silently replacing missing fields with null.
Such behavior silently breaks data without warning. The client thought they were updating only priority, and you zeroed out description and assigneeName because “they were not sent.” If you want a full replacement, that is PUT. If you want a partial update, you must interpret omissions as “do not change.”
Mistake #2: reusing one DTO for PUT and PATCH.
This looks economical (“fewer classes!”), but in practice you erase the boundary between contracts. Endless confusion begins: which fields are required, which are not, what does missing field mean, how do you validate. A separate TaskPutRequest and a separate TaskPatchRequest are not bureaucracy, but a way to make the API predictable.
Mistake #3: disable validation “because it is a partial update.”
You quickly get garbage values and strange bugs: titles with two characters, priorities outside the enum, empty strings where they should not be. In PATCH, validation simply becomes conditional: we check the fields that arrived and do not require the ones that did not.
Mistake #4: accept an empty {} as a “successful update” without an explicit rule.
Sometimes that is acceptable, but more often it signals that the client made a mistake. In a learning project, it is more useful to return 400 and say “send at least one field to update,” so that client code does not make accidental no-op requests that are later hard to distinguish from real changes.
Mistake #5: expose server-managed fields through PATCH.
If your patch DTO contains id, createdAt, updatedAt, or other internal attributes, you have allowed the client to break your system. The most reliable protection is to design the DTO so the client physically cannot send what it is not allowed to change.
GO TO FULL VERSION