1. TaskPatchRequest как отдельный DTO
Когда впервые сталкиваешься с PATCH, очень хочется сделать “по-быстрому”: взять уже существующий TaskCreateRequest или даже TaskDetailsResponse, выкинуть пару полей и сказать: “Ну вот, у меня patch DTO”. Это нормальный человеческий порыв: мозг любит переиспользование. Но в контрактном API переиспользование часто превращается в скрытую утечку смысла: DTO начинает жить сразу в нескольких сценариях, и вы теряете возможность честно описать, что именно разрешено клиенту, а что нет.
Главная идея здесь простая: TaskPatchRequest — это transport-модель для частичного изменения. У неё другая цель, чем у create/update моделей. Create говорит: “Вот данные для создания ресурса”. Detail response говорит: “Вот как ресурс выглядит наружу”. Patch говорит: “Вот какие кусочки ресурса я хочу поменять”. Это вообще разные жанры текста, примерно как “рецепт”, “фото блюда” и “список замен ингредиентов”. Пытаться сделать один DTO на все случаи — всё равно что печатать резюме, договор и открытку маме одним и тем же шаблоном Word’а.
Есть ещё один важный момент, который особенно цепляет новичков: patch DTO не является “урезанной полной моделью”. Он является намеренно узким. В нём должны остаться только те поля, которые мы действительно готовы менять “точечно”, и которые мы понимаем, как менять без двусмысленности. Всё остальное туда не входит не потому, что “нам жалко”, а потому что мы проектируем контракт так, чтобы он был предсказуемым для клиента и безопасным для сервера.
2. Поля для patch DTO
Перед тем как написать record TaskPatchRequest(...), полезно остановиться и сделать то, что программисты обычно не любят: на минуту подумать. PATCH — это история про “я меняю вот это”, значит нам нужно ответить на вопрос: а что именно клиенту разрешено менять частично? И тут очень удобно разделить все поля Task на две большие группы: то, чем управляет клиент, и то, чем управляет сервер.
Сервер-управляемые поля — это не “секретные поля для элиты”. Это просто поля, которые задаются системой: идентификатор, моменты времени, иногда вычисляемые значения, иногда инфраструктурные ключи. Они могут быть видны в response (например, id почти всегда виден), но их нельзя принимать как вход для изменений. Если вы разрешите клиенту присылать createdAt, то вы в какой-то момент получите задачу, которая “создана завтра” (и в этот момент вселенная не схлопнется, но отчёты точно).
Давайте для нашего Task (из Task Tracker API) посмотрим на поля и честно отметим: какие поля patchable, а какие нет.
| Поле внутренней модели Task | Откуда берётся “истина” | Должно быть в TaskPatchRequest? | Почему |
|---|---|---|---|
| id | сервер (UUID) | нет | идентификатор задаётся сервером и адресует ресурс через path |
| createdAt | сервер | нет | server-managed, не меняется клиентом |
| updatedAt | сервер | нет | server-managed, вычисляется после изменений |
| status | доменная логика | обычно нет (в рамках дня) | статусные переходы часто требуют отдельной логики/правил, лучше не смешивать с patch полей |
| title | клиент | да | часть изменяемого состояния (но со своими правилами) |
| description | клиент | да | необязательное поле, как раз “любимчик” patch-сценариев |
| assigneeName | клиент | да | может быть назначен/очищен |
| dueDate | клиент | да | удобный пример поля, которое часто меняют отдельно |
| priority | клиент | да | enum, хорошо ложится в patch |
| tags | клиент | да | но с явной семантикой (обычно “замена списка целиком”) |
Заметьте: мы не спорим, “можно ли вообще когда-нибудь менять статус”. Можно. Просто в рамках этого дня мы строим понятный patch-like контракт, и status — слишком “богатое” поле, которое часто связано с бизнес-переходами и конфликтами. Если мы засунем status в patch DTO без заранее согласованных правил, то завтра получим “PATCH и изменение статуса как попало”, а послезавтра — “почему у нас нельзя ограничить переходы?”. То есть вместо API-контракта получится лотерея.
Из этого следует практическое правило: в patch DTO попадают только “обычные” изменяемые поля. Если поле тянет за собой бизнес-процесс (например, переход статуса), лучше не пытаться прятать его в “общий мешок PATCH”, пока вы не готовы проектировать этот бизнес-процесс отдельно и осознанно.
3. TaskPatchRequest в коде
Теперь мы готовы перейти от “что хотим” к “как это выглядит в Java”. И здесь важно не устроить комедию вида: “А давайте все поля сделаем Object, чтобы Jackson точно всё прочитал”. Jackson, конечно, прочитает. Но потом вы будете читать это в сервисе и тихо плакать в debug-лог.
Patch DTO в нашем курсе остаётся типизированным. Это не “динамический JSON мешок”, а аккуратная модель входа. Поэтому поля мы выбираем в соответствии с тем, что уже показывали в JSON-baseline: строки остаются строками, даты — LocalDate, перечисления — enum, коллекции — List<String>.
Вот канонический пример:
package com.example.tasktracker.api.dto.request;
import java.time.LocalDate;
import java.util.List;
import com.example.tasktracker.domain.model.TaskPriority;
/**
* DTO для PATCH: все поля опциональны на уровне Java-модели.
* Сам DTO не различает `absent` и `explicit null` — эту грань задаёт контракт
* и способ чтения входа.
*/
public record TaskPatchRequest(
// Новое название задачи (если клиент хочет изменить именно его)
String title,
// Новое описание (частый сценарий: добавить/исправить текст)
String description,
// Исполнитель: может быть задан или очищен (в зависимости от правил обработки null)
String assigneeName,
// Срок выполнения: локальная дата без времени
LocalDate dueDate,
// Приоритет: enum, чтобы избежать "магических строк" на входе
TaskPriority priority,
// Теги: обычно воспринимаем как "заменить список целиком", а не "добавить один тег"
List<String> tags
) {
}
Почему это выглядит именно так (и почему это нормально):
Мы сознательно делаем поля nullable. В patch-модели “необязательность” — это не про “нам всё равно”, а про то, что Java-представление должно допускать отсутствие значения. Но сам DTO ещё не хранит факт присутствия поля в JSON, поэтому absent и explicit null нельзя склеивать в одну фразу “null = не менять”. Для description null может быть командой очистки, а для title — уже ошибкой входа. Поэтому patch DTO лучше читать как список потенциально изменяемых полей, а не как готовую merge-политику.
Также обратите внимание на tags. Частая ошибка новичка — засунуть теги в Set<String> и порадоваться “уникальности”. Но JSON — это не Set, это список значений, и для DTO проще использовать List<String>, а уникальность и ограничения на размер/формат — это отдельная тема (позже в модуле validation). Сейчас нам важно, чтобы контракт был понятным для клиента: он присылает массив строк — мы читаем массив строк.
4. taskId: path отдельно от body
Очень хочется добавить в TaskPatchRequest поле id, чтобы “было удобно”. Это классика: “чтобы в одном объекте было всё”. Но REST-контракт как раз и пытается избавить нас от такого “всё в одном”. В нашем API адрес ресурса — это path, а описание изменения — это body. Смешивание этих ролей ухудшает читаемость контракта и добавляет вам лишние проверки.
Если taskId приходит и в path, и в body, появляется вопрос: “А если они разные, кому верить?”. И вы внезапно пишете логику сравнения, ошибки вида “id mismatch”, и всё это только потому, что вы однажды решили “сделаем удобненько”. Это как поставить два руля в машину: да, теоретически ими можно управлять, но вы сами создали проблему синхронизации.
Нормальная модель выглядит так:
flowchart TD
%% Адрес ресурса (path) и данные изменения (body) не смешиваем в одном DTO
A["PATCH /api/v1/tasks/{taskId}"] --> B["taskId (path) = адрес ресурса"]
A --> C["body = TaskPatchRequest = изменения"]
C --> D["title/description/... (часть может отсутствовать)"]
Именно так мы и хотим видеть нашу сигнатуру контроллерного метода: один аргумент для идентификатора, один аргумент для patch DTO.
Пример контроллера в стиле проекта:
package com.example.tasktracker.api.controller;
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;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
@RestController
public class TaskController {
@PatchMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse patchTask(
// Идентификатор ресурса берём из path: он отвечает за "что меняем"
@PathVariable String taskId,
// DTO в body отвечает только за "как меняем"
@RequestBody TaskPatchRequest request
) {
// Здесь важна сама граница: path задаёт ресурс, body описывает изменения.
// Правила merge и проверки входа живут дальше по цепочке.
throw new UnsupportedOperationException("Not implemented");
}
}
Такой скелет фиксирует внешний shape endpoint’а: taskId в path, patchable-поля в body. Можно по-разному реализовать чтение тела запроса, чтобы не потерять факт присутствия поля, но сама граница path/body от этого не меняется.
5. Ответ после PATCH
Есть соблазн сделать так: “Клиент прислал TaskPatchRequest, ну и вернём ему обратно TaskPatchRequest — экономия же!”. Экономия, конечно, есть. Экономия смысла. После PATCH клиент обычно хочет понять, каким ресурс стал. А patch DTO — это не “ресурс стал”, это “что мы пытались поменять”.
Поэтому внешний контракт здорового API выглядит так: вход — patch DTO, выход — полноценный response DTO (обычно detail-модель), в которой сервер показывает актуальное состояние ресурса после изменения. Это особенно важно, потому что сервер может:
переупорядочить теги, применить нормализацию, обновить updatedAt, вычислить какие-то derived поля или просто вернуть актуальный снимок. Клиенту полезнее увидеть “истину сервера”, чем ещё раз увидеть то, что он и так отправил.
Поэтому мы держим архитектурную дисциплину: в PATCH-endpoint мы будем возвращать TaskDetailsResponse (или аналогичную detail response модель). И это не “лишнее”. Это делает API предсказуемым: после любого изменения клиент может получить подтверждение, что сервер понял его правильно.
Антипример: response DTO на вход
Теперь давайте закрепим плохой пример, потому что иногда без плохого примера хороший не запоминается. Допустим, у нас есть TaskDetailsResponse (response DTO), и мы решили: “А давайте примем его как request, он же уже есть”.
Что может пойти не так? Почти всё.
package com.example.tasktracker.api.controller;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
public class BadTaskController {
@PatchMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse patchTask(
// Даже если id берём из path, проблема остаётся: body становится слишком "широким"
@PathVariable String taskId,
// ПЛОХО: response DTO на вход => клиент может присылать server-managed поля и лишние данные
@RequestBody TaskDetailsResponse request
) {
// Заглушка: пример нужен как иллюстрация антипаттерна, а не как рабочий код
throw new UnsupportedOperationException();
}
}
Снаружи это выглядит “логично”. Но контрактно это означает: клиент теперь может присылать всё, что вы показывали в detail response. А detail response почти наверняка содержит server-managed поля (id, createdAt, updatedAt) и, возможно, поля, которые вы вообще не хотите менять таким способом. Вы только что сделали API широким и плохо управляемым.
Плюс вы смешали две роли: response DTO обычно оптимизирован под чтение, а request DTO под вход. У request DTO могут быть другие ограничения, другие названия, другое отношение к null и отсутствие поля. Когда вы переиспользуете response DTO на вход, вы теряете возможность нормально управлять этими различиями.
И да, отдельно “вишенка на торте”: вы повышаете риск того, что кто-то когда-нибудь добавит в TaskDetailsResponse новое поле (например, archived или internalFlags), и внезапно это поле начнут присылать клиенты. А вы это даже не планировали. Такие баги особенно веселы тем, что выглядят как “вроде всё работало, а теперь странно”.
6. Типичные ошибки при проектировании TaskPatchRequest
Ошибка №1: “Сделаю patch DTO копией detail response, только без пары полей”.
Такой подход почти всегда приводит к расширению контракта без вашего согласия. У detail response и patch input разные цели. Detail response должен показывать состояние ресурса, а patch DTO должен ограничивать, что можно менять. Если вы путаете эти роли, вы сами себе создаёте будущие breaking changes и проблемы с over-posting.
Ошибка №2: добавление server-managed полей “для удобства”.
Чаще всего это id, createdAt, updatedAt. В patch DTO им не место. Иначе вам придётся придумывать, что делать с “изменением createdAt”, а это обычно не то, чем хочется заниматься в здравом уме. Правильная модель: taskId приходит через path, таймстемпы обновляет сервер.
Ошибка №3: попытка сделать “универсальный DTO на все операции”.
Один DTO на create, put, patch, response — звучит как экономия, но по факту это отключение контрактной дисциплины. API становится нечётким: непонятно, какие поля обязательны, какие можно присылать, какие игнорируются, какие запрещены. В нашем курсе подход обратный: одна операция — одна ясная модель входа/выхода.
Ошибка №4: включение “сложных” полей без договорённости о смысле.
Например, статус задачи. Если включить status в patch DTO без правил, вы очень быстро получите “PATCH меняет статус как попало”, а потом попытку прикрутить ограничения задним числом. Это больно и для кода, и для клиентов. Лучше держать patch DTO сфокусированным на полях, которые действительно удобно менять частично.
Ошибка №5: смешивание адреса ресурса и описания изменения.
Если taskId (или id) приходит одновременно и в path, и в body, вы вынуждены решать конфликт значений. Это искусственная проблема. Адрес ресурса должен быть в path, изменения — в body. Так контракт читается проще и для человека, и для клиентского кода.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ