CodeGym /Courses /Spring REST & MVC /Patch DTO: updating Task without loss

Patch DTO: updating Task without loss

Spring REST & MVC
Level 13, Lesson 2
Available

1. PATCH: working with the current Task

When you see a patch payload like {"title":"Refine API"}, your brain (especially after a week of CRUD suffering) suggests: "Well, let's create a new Task, put the title there, and save it." But that is a trap. PATCH is always an operation on an existing resource, where the current fields are already filled in, and the client is explicitly saying: "Please change these pieces, and leave the rest as is." So our server-side algorithm must start not with "create a new object", but with "find the current one".

If we state this idea literally, PATCH almost always looks like a two-step process: first we load the current task, then we compute the new state by applying the patch on top of the old one. Beginners often fall into an "internal PUT": they do not notice that they have accidentally performed a full replacement (or "full loss"), even though they promised the client a partial update.

A small diagram that helps keep the thread:

flowchart TD
    %% Two-step process: load current Task → apply patch → save
    A["PATCH /api/v1/tasks/{taskId}"] --> B["Controller: accepts TaskPatchRequest"]
    B --> C["Service: loads current Task"]
    C --> D["Apply patch: field by field"]
    D --> E["Repository: saves updated Task"]
    E --> F["Mapper: Task -> TaskDetailsResponse"]

Pay attention to the key point: in the middle there is always a "current Task". Without it, we do not know what exactly to "leave unchanged".

2. Merge logic: not in the controller

It is very tempting to do everything "quickly" right in the controller: after all, the TaskPatchRequest is already in your hands, and all that remains is to call a couple of set...() methods and you are done. But in our course, the controller is the place where we describe the HTTP contract, not the place where the domain logic for state changes lives. As soon as merge logic lands in the controller, it starts to bloat: conditions appear there, rules for collections, updating updatedAt, and then "oh, and archived tasks must not be changed" and "oh, and this status must not be allowed". And that is it — the controller turns into a mini-service, only worse.

The right compromise for a learning project is to keep merge logic in the service or in a small separate "applier" class (sometimes it makes sense to consider it part of the mapper layer, but in terms of meaning it is closer to service-side state-change logic). The controller, in turn, should remain straightforward: take the input, pass it along, return the response.

If we look specifically at where the merge logic lives, the response type is secondary here. So the example below is intentionally simplified: it shows only one thing — the controller delegates the change further instead of merging the fields itself. The full HTTP contract, however, still usually returns the current resource state.

For example, a controller can look as boring as possible:

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 {

    // In a real project, taskService will be injected through DI (constructor/field injection)
    // private final TaskService taskService;

    @PatchMapping("/api/v1/tasks/{taskId}")
    public void patch(@PathVariable String taskId, @RequestBody TaskPatchRequest request) {
        // The controller does NOT do the merge: it only accepts HTTP input and passes control further
        taskService.patch(taskId, request);
    }
}

Yes, here I intentionally return void so we do not drift into discussing what exactly to return and which statuses to choose. Right now, what matters more is the merge logic location. The point is that the controller should not inside itself sort out the rules of patch.

3. Merge strategy: field by field

In programming there is a special kind of magic: the more boring the code is, the more often it turns out to be correct. For PATCH, this is especially true. The most readable strategy is explicit application of patch fields one by one, with a clear rule for each field. It looks like a series of if statements, and yes, the first reaction is usually: "ugh, so many ifs." But these ifs are your contract. They make the behavior transparent: anyone opens the method and sees exactly what will change if a field is present.

From an algorithm perspective, we do the following: take the current Task, then for each patchable field check whether a value arrived, and only then update it. It is important to understand that this is not "checking for emptiness for the sake of beauty", but protection against data loss.

A minimal applier example (roughly, part of TaskService or a separate class) might look like this:

public Task applyPatch(Task current, TaskPatchRequest patch) {
    // The first safe step: update only the fields for which we already see a new value.
    // Such a merge protects against a hidden PUT, but it still does not distinguish absent from explicit null.
    if (patch.title() != null) {
        current.setTitle(patch.title());
    }
    if (patch.priority() != null) {
        current.setPriority(patch.priority());
    }

    // Return the same object: PATCH is applied "on top" of the current state
    return current;
}

Here we implemented the safest starting option: we do not erase data until we see a new value. This code is good as the first line of defense against a hidden PUT, but it only works for fields where null does not carry a separate command. As soon as null starts meaning "clear it" or "input error", a simple != null check is no longer enough.

4. null and a missing field in patch

null is such a character that in Java it can mean "nothing", "I do not know", and "everything is gone" all at once. In a patch scenario, null becomes even trickier: sometimes null is a command to clear a field, and sometimes null is simply a lack of information, because the field was not sent at all. In a good PATCH contract, we distinguish three states: the field is missing, the field is present with null, the field is present with a new value. It is this distinction that keeps PATCH predictable.

But here is the harsh truth of a basic DTO: if we use ordinary fields like String description, then both "absent field" and "explicit null" end up as the same value, null. And then our if rule (patch.description() != null) will not be able to implement field clearing through null, because it will not distinguish "clear" from "do not touch" at all.

So in practice, we usually go in two steps.

The first step is safe: treat null as "do not change". This is not perfect, but it is very predictable, and it guarantees that you will not lose data by accident.

The second step is precise: be able to distinguish "the field was sent" from "the field was not there". Then null can be interpreted differently for different fields: somewhere as a command to "clear", and somewhere as an input error.

To avoid leaving everything in the air, let's fix this at the project level for Task:

Field If the field did not arrive If the field arrived as null If the field arrived with a value
title (required) do not change input error replace
description (optional) do not change clear replace
assigneeName (optional) do not change unassign replace
priority (enum) do not change input error replace
dueDate (date) do not change remove the deadline replace
tags (list) do not change input error; for clearing, it is better to use [] replace the list

The point of this table is that the rule for null cannot be made "one for all". Every field has its own domain semantics, and a good API should make that explicit rather than forcing the client to guess.

5. Collections in patch: the rule for tags

Collections in a patch scenario are a separate level of adventure. If strings are relatively simple ("replace the string"), then as soon as a tag list appears, questions arise: is it a full replacement? an addition? a removal? something "smart"? And this is where chaos usually begins: the server tries to guess what the client "meant", and the client tries to guess what the server "decided".

For a learning project, we need simple and verifiable semantics. The calmest option is: if the tags field arrived, we assume the client sent a complete new list, and we simply replace the old list with the new one. It is easy to test, easy to document, and hard to break accidentally.

The code looks short, but there is an important detail here: we copy the list instead of keeping a reference to the list from the DTO. Otherwise, you can get strange effects if someone changes that list somewhere else (yes, a record does not make collections immutable automatically).

import java.util.List;

if (patch.tags() != null) {
    // Important: copy the list so the DTO and domain model do not share the same collection
    current.setTags(List.copyOf(patch.tags()));
}

If you try to do a "per-element merge" right now, you will very quickly fall into diff strategies, conflicts, and "what do we do with duplicates". All of that really happens in large systems, but for a course on REST contracts, that is premature pain. We choose one clear contract and stick to it.

6. Anti-pattern: blind assignment

Now let's look at an anti-example. It shows up surprisingly often because it looks "natural" and even "tidy": just transfer values from the patch DTO into the current task. The problem is that null in a patch DTO can mean "the field did not arrive", and with blind assignment null turns into "the value was deleted".

Here is the classic bad merge:

public Task badApply(Task current, TaskPatchRequest patch) {
    // Anti-pattern: blindly copy everything → missing fields become null and overwrite data
    current.setTitle(patch.title());
    current.setDescription(patch.description());
    current.setAssigneeName(patch.assigneeName());
    return current;
}

If the client sent {"title": "Refine API"}, then description() and assigneeName() in the DTO will be null. And your code will happily erase the description and assignee. The user will be sure they changed only the title, while the server will quietly do a "full cleanup" of the data for them. This is the kind of mistake that is hard to spot by eye in a code review (because the code is short and "pretty"), but very easy to spot in production — from the clients' screams.

A separate subtype of this anti-example is the attempt to use automatic property copiers (like "copy all non-null fields with reflection"). From the outside, it looks like a time saver, but in fact you are hiding the contract in magic. Later someone adds a new field, and it suddenly starts being patched without discussion. Congratulations: you have just created a "default contract" that nobody ever specified.

7. TaskPatchApplier: moving the merge logic out

Even if you love ifs (and who does not, except people who have seen 300 of them in a row), over time patch logic starts to grow: fields appear, exceptions appear, the need to normalize input appears, and updatedAt needs to be updated. To avoid turning TaskService into a bottomless file called "TaskService.java (final FINAL v12).java", it is convenient to move the merge into a separate small class.

It does not need to be smart. Its job is to be readable and predictable. Here is the minimal version:

import java.util.List;

public class TaskPatchApplier {

    public void apply(Task task, TaskPatchRequest patch) {
        // Basic safe-first step: update only the fields for which we already have a new value.
        // When the difference between absent and explicit null matters for a field,
        // an explicit check for field presence in JSON is added here as well.
        if (patch.title() != null) task.setTitle(patch.title());

        if (patch.tags() != null) {
            // Protect against a "shared" collection between the DTO and the domain model
            task.setTags(List.copyOf(patch.tags()));
        }
    }
}

For tags, this is already enough: null changes nothing, and the list is replaced entirely. For fields like title, the final contract usually goes one step further: it is important to distinguish absent from explicit null, so that null becomes an input error rather than a silent no-op. But the principle remains the same — no magic, just an explicit merge over the current state.

The service, in turn, does what a service should do: fetch the task, apply the changes, save it.

import java.time.Instant;

public void patch(String taskId, TaskPatchRequest patch) {
    // 1) Always load the current resource first (PATCH works "on top" of it)
    Task task = taskRepository.getRequired(taskId);

    // 2) Apply changes according to merge rules (explicitly, field by field)
    taskPatchApplier.apply(task, patch);

    // 3) Server-managed field: update the modification time on the server side
    task.setUpdatedAt(Instant.now());

    // 4) Save the final state
    taskRepository.save(task);
}

Here getRequired(...) is a hypothetical repository method that "either found it or threw an exception". We are intentionally not going into which exact exception it is and how it turns into an HTTP error, because that is a separate block of the course. But the structure is already correct: service orchestrates, applier merges, repository stores.

8. Typical mistakes with a patch DTO

Mistake #1: turning PATCH into an implicit PUT.
When you simply copy fields from the DTO into the entity, missing fields become null and overwrite existing values. This breaks the very idea of partial update. The correct model is to update only the fields that actually arrived in the request and are allowed by the contract.

Mistake #2: keeping merge logic in the controller.
When there are few fields, this looks harmless. But as complexity grows, the controller starts to contain business rules, and the controller → service boundary becomes blurred. In the end, the code becomes harder to test and maintain. Merge is part of the business logic and should live in the service or in a separate component.

Mistake #3: passing collections "by reference".
If you simply assign tags from the DTO to the domain model, you risk unexpected state changes from other parts of the code. The DTO and the domain start sharing the same collection. A simple copy (List.copyOf) breaks this link and makes behavior predictable.

Mistake #4: making a "universal merge" to save code.
Trying to remove ifs and write a general update mechanism almost always leads to implicit rules. New fields start updating "by themselves", without an explicit decision. This makes the API less controllable and complicates its evolution. Explicit, even slightly "boring" code is safer here.

Mistake #5: ignoring server-managed fields.
Some fields must not be changed by the client (id, createdAt, system statuses). If you do not protect them, they may be accidentally overwritten through patch. That is no longer just a bug, but a violation of the API contract. In merge logic, it is important to explicitly exclude such fields from updates.

1
Task
Spring REST & MVC, level 13, lesson 2
Locked
Applying patch DTO to the current state of a task
Applying patch DTO to the current state of a task
1
Task
Spring REST & MVC, level 13, lesson 2
Locked
Full replacement of the list on article PATCH
Full replacement of the list on article PATCH
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION