1. PATCH: працюємо з поточним Task
Коли бачите patch-пейлоад на кшталт {"title":"Refine API"}, мозок (особливо після тижня CRUD-страждань) підказує: «Ну то створімо новий Task, запишемо туди title і збережемо». Але це пастка. PATCH — це завжди операція над наявним ресурсом, у якого вже є поточні поля, а клієнт явно каже: «Будь ласка, змініть ось ці шматочки, а решту залиште як було». Тому наш серверний алгоритм має починатися не зі «створити новий об’єкт», а зі «знайти поточний».
Якщо сформулювати цю ідею буквально, PATCH майже завжди виглядає як двокроковий процес: спершу ми завантажуємо поточне завдання, потім обчислюємо новий стан, застосовуючи patch поверх старого. У новачків часто трапляється «внутрішній PUT»: вони не помічають, що випадково зробили повну заміну або навіть повну втрату, хоча обіцяли клієнту часткове оновлення.
Невелика схема, яка допомагає не втрачати нитку:
flowchart TD
%% Двокроковий процес: завантаження поточного Task → застосування patch → збереження
A["PATCH /api/v1/tasks/{taskId}"] --> B["Контролер: приймає TaskPatchRequest"]
B --> C["Сервіс: завантажує поточний Task"]
C --> D["Застосування patch: поле за полем"]
D --> E["Репозиторій: зберігає оновлений Task"]
E --> F["Mapper: Task -> TaskDetailsResponse"]
Зверніть увагу на ключову точку: посередині завжди є «поточний Task». Без нього ми не знаємо, що саме не змінювати.
2. Merge-логіка: не в контролері
Дуже хочеться зробити все «швиденько» прямо в контролері: адже TaskPatchRequest уже в руках, залишилося тільки викликати пару set...() — і готово. Але контролер у нашому курсі — це місце, де ми описуємо HTTP-контракт, а не там, де живе доменна логіка зміни стану. Щойно merge-логіка потрапляє в контролер, він починає розростатися: там зʼявляються умови, правила для колекцій, оновлення updatedAt, а потім підтягуються вимоги «а ще не можна змінювати архівне завдання» і «а ще не можна ось такий статус». І все — контролер перетворюється на мінісервіс, тільки гірший.
Правильний компроміс для навчального проєкту — тримати merge-логіку в сервісі або в окремому невеликому класі-«аплаєрі» (інколи його логічно вважати частиною mapper-шару, але за змістом він ближчий до сервісної логіки зміни). Контролер же має залишатися прямолінійним: взяти вхід, передати далі, повернути відповідь.
Якщо дивитися саме на місце merge-логіки, тип відповіді тут другорядний. Тому приклад нижче навмисно спрощений: він показує лише одне — контролер делегує зміну далі, а не мержить поля сам. Повний HTTP-контракт при цьому все одно зазвичай повертає актуальний стан ресурсу.
Наприклад, контролер може виглядати максимально «нудно»:
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 {
// У реальному проєкті taskService буде впроваджено через DI (конструктор/field injection)
// private final TaskService taskService;
@PatchMapping("/api/v1/tasks/{taskId}")
public void patch(@PathVariable String taskId, @RequestBody TaskPatchRequest request) {
// Контролер НЕ робить merge: він лише приймає HTTP-вхід і передає керування далі
taskService.patch(taskId, request);
}
}
Тут я спеціально повертаю void, щоб не відволікатися на обговорення того, що саме повертати і які статуси обирати. Нам зараз важливіше місце merge-логіки. Суть у тому, що контролер не має всередині себе розвʼязувати правила patch.
3. Стратегія merge: поле за полем
У програмуванні є особлива магія: що нудніший код, то частіше він виявляється правильним. Для PATCH це особливо справедливо. Найчитабельніша стратегія — явне застосування patch-полів по одному, з чітким правилом для кожного. Це виглядає як серія if, і так, перша реакція зазвичай: «фу, скільки if». Але ці if — це і є ваш контракт. Вони роблять поведінку прозорою: будь-хто відкриває метод і бачить, що саме зміниться, коли поле надійшло.
З погляду алгоритму ми робимо таке: беремо поточний Task, потім для кожного patchable-поля перевіряємо, чи прийшло значення, і лише тоді оновлюємо. Важливо розуміти: це не «перевірка на порожнечу заради краси», а захист від втрати даних.
Мінімальний приклад аплаєра (умовно, частина TaskService або окремий клас) може виглядати так:
public Task applyPatch(Task current, TaskPatchRequest patch) {
// Перший безпечний крок: оновлюємо лише ті поля, для яких уже бачимо нове значення.
// Такий merge захищає від прихованого PUT, але ще не розрізняє absent і explicit null.
if (patch.title() != null) {
current.setTitle(patch.title());
}
if (patch.priority() != null) {
current.setPriority(patch.priority());
}
// Повертаємо той самий об’єкт: PATCH застосовуємо "поверх" поточного стану
return current;
}
Тут ми реалізували найбезпечніший стартовий варіант: не стираємо дані, доки не бачимо нового значення. Такий код хороший як перша лінія оборони проти прихованого PUT, але він годиться лише для полів, де null не несе окремої команди. Щойно null починає означати «очистити» або «помилка входу», однієї перевірки != null уже мало.
4. null і відсутність поля в patch
null — це такий персонаж, який у Java одночасно означає «нічого», «не знаю» і «все пропало». У patch-сценарії null стає ще хитрішим: інколи null — це команда очистити поле, а інколи null — це просто відсутність інформації, бо поле взагалі не надіслали. У хорошому PATCH-контракті ми розрізняємо три стани: поле відсутнє, поле присутнє з null, поле присутнє з новим значенням. Саме це розрізнення й робить PATCH передбачуваним.
Але ось сувора правда базового DTO: якщо ми використовуємо звичайні поля типу String description, то і «absent field», і «explicit null» у підсумку перетворюються на одне й те саме значення null. І тоді наше правило if (patch.description() != null) не зможе реалізувати «очищення» поля через null, бо воно взагалі не відрізнить «очистити» від «не чіпати».
Тому на практиці зазвичай вдаються до двох кроків.
Перший крок — безпечний: вважати null = «не змінювати». Це не ідеальна, але дуже передбачувана стратегія, яка гарантує, що ви не втратите дані випадково.
Другий крок — точний: уміти відрізняти «поле прийшло» від «поля не було». Тоді null уже можна трактувати по-різному для різних полів: десь як команду «очистити», а десь як помилку входу.
Щоб не залишати все в повітрі, зафіксуймо це на рівні проєкту для Task:
| Поле | Якщо поле не надійшло | Якщо поле надійшло як null | Якщо поле надійшло зі значенням |
|---|---|---|---|
| title (обов’язкове) | не змінювати | помилка входу | замінити |
| description (необов’язкове) | не змінювати | очистити | замінити |
| assigneeName (необов’язкове) | не змінювати | зняти призначення | замінити |
| priority (enum) | не змінювати | помилка входу | замінити |
| dueDate (дата) | не змінювати | прибрати дедлайн | замінити |
| tags (список) | не змінювати | помилка входу; для очищення краще використовувати [] | замінити список |
Сенс цієї таблиці такий: правило щодо null не можна зробити «одним для всіх». У кожного поля своя предметна семантика, і хороший API має її фіксувати, а не змушувати клієнта вгадувати.
5. Колекції в patch: правило для tags
Колекції в patch-сценарії — окремий рівень пригод. Якщо для рядка все відносно просто («замінити рядок»), то зі списком тегів одразу спливають питання: це повна заміна? це додавання? це видалення? це «зроби якось розумно»? І ось тут зазвичай народжується хаос: сервер намагається вгадати, що клієнт «мав на увазі», а клієнт намагається вгадати, що сервер «вирішив».
Для навчального проєкту нам потрібна проста й перевірювана семантика. Найспокійніший варіант: якщо поле tags надійшло — ми вважаємо, що клієнт надіслав повний новий список, і просто замінюємо старий список новим. Це легко тестувати, легко документувати і важко зламати випадково.
Код при цьому виглядає коротко, але тут є важлива деталь: ми копіюємо список, а не зберігаємо посилання на список із DTO. Інакше можна отримати дивні ефекти, якщо хтось десь цей список змінює (так, record не робить колекції immutable автоматично).
import java.util.List;
if (patch.tags() != null) {
// Важливо: копіюємо список, щоб DTO і доменна модель не ділили одну й ту саму колекцію
current.setTags(List.copyOf(patch.tags()));
}
Якщо спробувати зробити «поелементний merge» просто зараз, ви дуже швидко опинитеся в темі diff-стратегій, конфліктів і «а що робити з дублікатами». У великих системах це справді трапляється, але для курсу з REST-контракту це передчасний біль. Ми обираємо один ясний контракт і тримаємося його.
6. Антипатерн: сліпе присвоювання
Тепер давайте подивимося на антиприклад. Він трапляється напрочуд часто, бо виглядає «природно» і навіть «акуратно»: просто перенести значення з patch DTO в поточне завдання. Проблема в тому, що null у patch DTO може означати «поле не надійшло», а при сліпому присвоюванні null перетворюється на «видалили значення».
Ось класичний поганий merge:
public Task badApply(Task current, TaskPatchRequest patch) {
// Антипатерн: сліпо копіюємо все підряд — відсутні поля перетворюються на null і затирають дані
current.setTitle(patch.title());
current.setDescription(patch.description());
current.setAssigneeName(patch.assigneeName());
return current;
}
Якщо клієнт надіслав {"title": "Refine API"}, то description() і assigneeName() у DTO стануть null. І ваш код радісно зітре опис та виконавця. Користувач буде впевнений, що змінював лише заголовок, а сервер тихо влаштує йому «генеральне прибирання» даних. Це та сама помилка, яку складно впіймати на code-review, бо код короткий і «красивий», але дуже легко впіймати в проді — за криком клієнтів.
Окремий підвид цього антиприкладу — спроба використовувати автоматичні копіювачі властивостей (на кшталт «скопіюй усі non-null поля рефлексією»). Ззовні здається, що це економія часу, а фактично ви ховаєте контракт у магію. Потім хтось додасть нове поле, і воно раптом почне патчитися без обговорення. Вітаю: ви щойно зробили «контракт за замовчуванням», який ніхто не фіксував.
7. TaskPatchApplier: виносимо merge-логіку
Навіть якщо ви любите if-и (а хто ж їх не любить, окрім людей, які бачили їх у кількості 300 штук підряд), з часом patch-логіка починає розростатися: зʼявляються поля, винятки, потреба нормалізувати вхід, оновлення updatedAt. Щоб не перетворювати TaskService на бездонний файл «TaskService.java (final FINAL v12).java», зручно винести merge в окремий маленький клас.
Він не має бути розумним. Його задача — бути читабельним і передбачуваним. Ось мінімальний варіант:
import java.util.List;
public class TaskPatchApplier {
public void apply(Task task, TaskPatchRequest patch) {
// Базовий безпечний перший крок: оновлюємо лише поля, для яких уже є нове значення.
// Коли для поля важлива різниця між absent і explicit null, до цього місця
// додають ще й явну перевірку присутності поля в JSON.
if (patch.title() != null) task.setTitle(patch.title());
if (patch.tags() != null) {
// Захищаємося від "спільної" колекції між DTO і доменною моделлю
task.setTags(List.copyOf(patch.tags()));
}
}
}
Для tags цього вже достатньо: null нічого не змінює, а список замінюється цілком. Для полів на кшталт title фінальний контракт зазвичай іде ще на крок далі: важливо відрізнити absent від явного null, щоб null став помилкою входу, а не тихим no-op. Але принцип лишається тим самим — ніякої магії, тільки явний merge поверх поточного стану.
Сервіс при цьому робить те, що сервіс має робити: дістати завдання, застосувати зміни, зберегти.
import java.time.Instant;
public void patch(String taskId, TaskPatchRequest patch) {
// 1) Завжди спершу завантажуємо поточний ресурс (PATCH працює "поверх" нього)
Task task = taskRepository.getRequired(taskId);
// 2) Застосовуємо зміни за правилами merge (явно, поле за полем)
taskPatchApplier.apply(task, patch);
// 3) Поле, яким керує сервер: оновлюємо час зміни на боці сервера
task.setUpdatedAt(Instant.now());
// 4) Зберігаємо підсумковий стан
taskRepository.save(task);
}
Тут getRequired(...) — умовний метод репозиторію, який або знайшов, або кинув виняток. Ми спеціально не заглиблюємося в те, який саме виняток і як він перетвориться на HTTP-помилку, тому що це окремий блок курсу. Але структура вже правильна: сервіс координує, аплаєр об’єднує зміни, репозиторій зберігає.
8. Типові помилки при patch DTO
Помилка № 1: перетворювати PATCH на неявний PUT.
Коли ви просто копіюєте поля з DTO в сутність, відсутні поля стають null і затирають наявні значення. Це ламає саму ідею часткового оновлення. Правильна модель — оновлювати лише ті поля, які реально надійшли в запиті й дозволені контрактом.
Помилка № 2: тримати merge-логіку в контролері.
Поки полів мало, це виглядає нешкідливо. Але зі зростанням складності контролер починає містити бізнес-правила, і межа controller → service розмивається. У підсумку код стає важче тестувати й підтримувати. Merge — це частина бізнес-логіки, і він має жити в сервісі або окремому компоненті.
Помилка № 3: передавати колекції «за посиланням».
Якщо ви просто присвоюєте tags з DTO у доменну модель, ви ризикуєте отримати неочікувані зміни стану з інших частин коду. DTO і домен починають ділити одну й ту саму колекцію. Просте копіювання (List.copyOf) розриває цей зв’язок і робить поведінку передбачуваною.
Помилка № 4: робити «універсальний merge» заради економії коду.
Спроба прибрати if-и й написати загальний механізм оновлення майже завжди призводить до неявних правил. Нові поля починають оновлюватися самі собою, без явного рішення. Це робить API менш керованим і ускладнює його еволюцію. Явний, навіть трохи «нудний», код тут безпечніший.
Помилка № 5: не враховувати server-managed поля.
Деякі поля не мають змінюватися клієнтом (id, createdAt, системні статуси). Якщо їх не захистити, вони можуть бути випадково перезаписані через patch. Це вже не просто баг, а порушення контракту API. У merge-логіці важливо явно виключати такі поля з оновлення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ