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["Controller: принимает TaskPatchRequest"]
B --> C["Service: загружает текущий Task"]
C --> D["Apply patch: поле-за-полем"]
D --> E["Repository: сохраняет обновлённый 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;
}
Тут мы реализовали самый безопасный стартовый вариант: не стираем данные, пока не видим нового значения. Такой код хорош как первая линия обороны против hidden 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 (потому что код короткий и “красивый”), но очень легко поймать в проде — по крику клиентов.
Отдельный подвид этого антипримерa — попытка использовать автоматические копировщики свойств (вроде “скопируй все non-null поля reflection’ом”). Снаружи кажется, что это экономия времени, а по факту вы прячете контракт в магию. Потом кто-то добавит новое поле, и оно внезапно начнёт патчиться без обсуждения. Поздравляю: вы только что сделали «контракт по умолчанию», который никто не фиксировал.
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) {
// Базовый safe-first-step: обновляем только поля, для которых уже есть новое значение.
// Когда для поля важна разница между 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) Server-managed поле: обновляем время изменения на стороне сервера
task.setUpdatedAt(Instant.now());
// 4) Сохраняем итоговое состояние
taskRepository.save(task);
}
Здесь getRequired(...) — условный метод репозитория, который “или нашёл, или кинул исключение”. Мы специально не углубляемся в то, какое именно исключение и как оно превратится в HTTP-ошибку, потому что это отдельный блок курса. Но структура уже правильная: service orchestrates, applier merges, repository stores.
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-логике важно явно исключать такие поля из обновления.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ