1. Смысл write-операций и аннотации
Когда начинаешь писать REST API, рука так и тянется: «ну вот тут @PostMapping, тут @PutMapping… и вроде готово». Но в зрелом API всё наоборот: сначала вы решаете, что именно означает операция для клиента, а потом выбираете HTTP-метод и оформляете это в Spring MVC. Иначе получается типичная история: код работает, но контракт читается как случайная смесь традиций, догадок и “а у нас в прошлом проекте было так”.
Write-операции особенно чувствительны, потому что они меняют состояние системы. Если GET вы ещё можете «чуть-чуть» сделать странно (хотя лучше не надо), то ошибки в POST/PUT/PATCH/DELETE быстро превращают API в место, где клиент боится лишний раз нажать кнопку «Сохранить». Нам нужно построить у клиента ощущение: “если я повторю запрос — я понимаю, что случится; если я отправлю неполные данные — я понимаю, что будет; если я удалю — я понимаю, чем это отличается от архивации”.
С read-стороной у нас уже всё неплохо: задача умеет жить как ресурс, мы умеем получать её списком, фильтровать и читать по id. Но у API до сих пор есть дыра: клиент должен так же предсказуемо создавать, заменять, частично менять и удалять этот же ресурс. Именно эту write-сторону CRUD мы сейчас и собираем.
В рамках нашего Task Tracker API мы сегодня фиксируем общий принцип: один ресурс Task поддерживает несколько операций записи, но каждая из них — отдельная договорённость, со своим смыслом и своим контрактом. И да, это именно договорённость: API — это не «набор возможностей сервера», это «правила игры» между клиентом и сервером.
2. Контракт записи ресурса
Термин «контракт записи» звучит слегка бюрократично, но на практике он спасает от хаоса. Контракт write-операции — это не только URI и HTTP-метод. Это полный набор обещаний: что клиент обязан прислать, что сервер обязан сделать, что вернёт в ответ, и как именно будут выглядеть ошибки. Если этот набор не зафиксировать, вы очень быстро получите «два разных клиента понимают один и тот же endpoint по-разному», а сервер на это отвечает философски: «ну… зависит».
В нашем курсе мы держим контракт write-операции как минимум в таком составе: URI, HTTP-метод, request DTO, response DTO, status code, headers (если нужны) и единые правила ошибок через ProblemDetail. Важно понимать, что некоторые элементы контракта могут быть «пустыми»: например, DELETE часто возвращает пустое тело, но это не означает, что контракт отсутствует — наоборот, он просто говорит: «тела нет, и это нормально».
Удобно воспринимать это как «паспорт операции». В коде этот паспорт отображается в сигнатуре метода контроллера (какие аргументы, какой DTO, какой ответ), в ResponseEntity (какой статус/headers) и в сервисной операции (какое состояние реально меняется). Если вы видите endpoint, но не можете быстро ответить на вопросы “что именно он обещает клиенту?”, “какие поля клиент может присылать?”, “что будет, если прислать только половину полей?” — значит контракт не определён, а endpoint пока «живет на удаче».
Давайте зафиксируем это в маленькой схеме (не юридической, а инженерной):
flowchart TD
%% Контракт: что клиент отправляет, что сервер обещает вернуть
Client["Клиент (UI/мобильное приложение)"] -->|HTTP метод + URI + request DTO| API["Task Tracker API"]
API -->|"Validation + business rules"| Service["Service layer"]
Service -->|изменение состояния| Repo["In-memory repository"]
API -->|"status + headers + response DTO"| Client
API -->|ProblemDetail при ошибке| Client
Мы не обсуждаем сейчас внутренности репозитория и “где хранится задача” — это уже сделано. Сегодня нам важно другое: контракт — это то, что видит клиент, а не то, как сервер устроен изнутри.
3. Write-операции для Task
Когда у нас есть один ресурс Task, он не обязан ограничиваться одной write-операцией. Наоборот, реальный клиентский сценарий почти всегда требует и создания, и полной замены, и частичного изменения, и удаления. Проблема начинается, когда эти операции начинают «имитировать» друг друга: PATCH работает как PUT, PUT работает как “частичное обновление”, DELETE внезапно делает “архивацию”, а POST иногда почему-то используется как “update, но через костыль”. Клиент в этот момент перестаёт верить API — и начинает писать обходные решения.
Чтобы не допустить этого, полезно держать простую матрицу. Она не заменяет документацию, но даёт мозгу «скелет», на который потом накладываются детали каждой операции.
| Операция | URI | Смысл для клиента | Типичный request DTO | Типичный response DTO | Типичный успех-статус |
|---|---|---|---|---|---|
| Create | POST /api/v1/tasks | «Создай новую задачу в коллекции» | TaskCreateRequest | TaskDetailsResponse | 201 Created |
| Full replace | PUT /api/v1/tasks/{taskId} | «Замени редактируемую часть задачи целиком» | TaskPutRequest | TaskDetailsResponse | 200 OK |
| Partial update | PATCH /api/v1/tasks/{taskId} | «Поменяй только то, что я явно прислал» | TaskPatchRequest | TaskDetailsResponse | 200 OK |
| Delete | DELETE /api/v1/tasks/{taskId} | «Удалить задачу как ресурс» | — | — | 204 No Content |
Сразу два важных уточнения, чтобы не поселить в голове неправильные ожидания. Во-первых, “полная замена” через PUT почти всегда означает “полная замена изменяемой части”, а не “переписать всё вообще, включая id и createdAt”. Это приводит нас к понятию server-managed полей: то, чем управляет сервер, клиент не должен присылать ни в create, ни в replace, ни в patch.
Во-вторых, PATCH — это не “PUT, только можно прислать меньше полей”. Это другая договорённость, и если мы её не оформим отдельным DTO, отдельной логикой и отдельным пониманием null/absent, то PATCH станет просто «вторым PUT», который выглядит короче, но ведёт себя непредсказуемее.
POST: создание
На уровне интуиции POST проще всего: клиент приходит к коллекции /tasks и говорит «создай мне новую задачу». Но даже здесь начинаются ошибки, если не зафиксировать контракт. Например, некоторые клиенты «по привычке» присылают id (потому что в их UI уже есть “какой-то id”), или пытаются выставить createdAt. Если сервер это «молча проглотит», вы создадите клиенту иллюзию контроля там, где его быть не должно.
Семантика POST в нашем курсе такая: POST создаёт новый ресурс. Поэтому типичный успех — 201 Created. В create-контракте почти всегда появляется вопрос: что вернуть в ответ? Мы заранее держим контракт понятным для клиента: либо возвращаем созданный TaskDetailsResponse, либо (если тело не нужно) хотя бы возвращаем Location, чтобы клиент мог сходить за созданным ресурсом. Здесь достаточно увидеть базовую вещь: POST — это про создание, и это различие должно читаться и в статусе, и в заголовках, и в DTO.
Мини-пример «запаха хорошего контракта» — request DTO для создания, в котором нет server-managed полей:
import jakarta.validation.constraints.NotBlank;
public record TaskCreateRequest(
// Клиент присылает только то, чем реально управляет (server-managed поля тут не живут)
@NotBlank String title
) {}
Да, это супер-укороченная версия. И это нормально для иллюстрации: контракт должен быть понятен даже в минимальном варианте.
PUT: полная замена
PUT выглядит как «обычное обновление», но его смысл строже: клиент говорит серверу “я присылаю тебе полную картину редактируемой части ресурса — сделай так, чтобы ресурс соответствовал этой картине”. Самая типичная ошибка здесь — реализовать PUT, но трактовать отсутствующие поля как “ну ладно, оставим старое”. Это не PUT. Это уже PATCH. И из-за этого клиент начинает думать, что PUT — это “обновить то, что есть”, а потом внезапно на другом проекте получает реальный PUT и теряет данные.
В нашем API PUT адресует конкретную задачу: /api/v1/tasks/{taskId}. И даже если у задачи 10 редактируемых полей, контракт PUT честно говорит: пришлите все 10 (или по крайней мере все обязательные редактируемые). В обмен вы получаете детерминированность. Клиентский код проще: “я всегда отправляю целую форму”. Сервер тоже проще: “я всегда знаю, что в replace-запросе лежит полный набор данных”.
При этом мы не отдаём клиенту власть над server-managed полями. Поэтому TaskPutRequest — это полная замена именно редактируемых полей, а id/createdAt/updatedAt живут отдельно и управляются сервером.
PATCH: частичное изменение
PATCH — это попытка сделать обновление более «экономным» по данным, но не ценой хаоса. Правильный PATCH — это когда клиент явно говорит: “вот эти поля меняю, остальные не трогаю”. Если поле не пришло — оно не участвует в изменении. И здесь появляются те самые tricky моменты, которые мы уже обсуждали раньше: чем отличается “поле отсутствует” от “поле пришло как null”? Можно ли null понимать как «очистить значение»? Или как «я не знаю, что туда поставить»? Если эти правила не зафиксировать, PATCH начинает жить “в зависимости от реализации сервера”.
Чтобы не размывать контракт, мы используем patch-like DTO. То есть мы заранее ограничиваем, какие поля вообще разрешено менять через PATCH, и даём этим полям понятную семантику. Это защищает нас от двух крайностей. Первая крайность — «универсальный Map<String, Object> и потом “как-нибудь разберём”». Вторая крайность — «давайте тащить RFC и делать полноценный JSON Patch». Для учебного production-like REST API разумнее быть посередине: typed DTO + понятные правила.
Вот минимальный пример patch DTO, в котором все поля optional (то есть могут быть null, если не пришли):
import jakarta.validation.constraints.Size;
public record TaskPatchRequest(
// Если поле не прислали — оно не участвует в изменении.
// Здесь для простоты показываем nullable-поле (по контракту трактуем как "не меняй").
@Size(min = 3, max = 120) String title
) {}
Пока мы не углубляемся в детали “как валидировать только пришедшие поля” — это у нас уже умеет Bean Validation в связке с тем, как мы формируем DTO и правила контракта. Сейчас нам важно зафиксировать смысл: PATCH меняет только то, что явно передано.
DELETE: удаление
DELETE — это не “обновление без тела”. Это отдельная семантика: удалить ресурс. В учебных проектах часто встречается соблазн: “а давайте DELETE будет просто ставить статус ARCHIVED”. С точки зрения бизнеса архивация может быть прекрасной идеей, но в терминах API-контракта это уже другая операция и другие правила ошибок. DELETE должен оставаться тем, что клиент ожидает: после него ресурс либо исчезает, либо становится недоступен как ресурс (а что именно происходит внутри — это уже внутренняя реализация).
На уровне транспорта DELETE относится к идемпотентным методам: повторный одинаковый запрос не должен бесконечно менять состояние всё сильнее и сильнее. Но тут важно не перепутать: идемпотентность — это про эффект на состояние, а не про “обязательно одинаковый ответ каждый раз”. Для delete-контракта здесь достаточно зафиксировать ядро: DELETE /api/v1/tasks/{taskId} удаляет задачу, типичный успех — 204 No Content, и при ошибке мы возвращаем тот же ProblemDetail-формат, что и везде.
4. Контракт в коде
Когда контракт у вас в голове «собран», код становится почти скучным — и это комплимент. Скучный код обычно означает, что вы заранее продумали смысл. В Spring MVC write-контракт читается прямо по контроллеру: по URI, по HTTP-методу, по входному DTO, по ответному типу и по ResponseEntity. А в сервисном слое он читается по названию операции: create, replace, patch, delete. Именно имена методов тут важны: они дисциплинируют мышление команды.
Начнём с сервиса. С точки зрения слоя domain.service (и не забываем: сервисный слой не должен знать про ResponseEntity и прочий MVC), нам нужны четыре операции, потому что это четыре разных намерения клиента. Даже если внутри вы их реализуете частично общим кодом, наружу они должны выглядеть раздельно: клиенту важно понимать смысл.
Ниже — минимальный пример интерфейса сервиса. Для простоты я показываю вариант, где сервис принимает request DTO и возвращает response DTO (это удобно для обучения сигнатурам). В более строгой слоистой архитектуре сервис мог бы работать с внутренней моделью Task, а маппинг происходил бы на границе API через TaskMapper, но семантика от этого не меняется.
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
public interface TaskWriteService {
// Создание нового ресурса (семантика POST)
TaskDetailsResponse create(TaskCreateRequest request);
// Полная замена редактируемой части ресурса (семантика PUT)
TaskDetailsResponse replace(String taskId, TaskPutRequest request);
// Частичное изменение только явно переданных полей (семантика PATCH)
TaskDetailsResponse patch(String taskId, TaskPatchRequest request);
// Удаление ресурса (семантика DELETE); обычно не возвращает тело
void delete(String taskId);
}
Теперь посмотрим на контроллер. Здесь важно не “как написать аннотацию”, а как сделать так, чтобы по коду читался контракт. Для этого мы не смешиваем всё в один “универсальный update”. Мы прямо называем методы create, replace, patch, delete. И да, это нормально, что в Java коде есть метод patch(): главное, чтобы человек, читающий контроллер, видел намерение.
Ниже — два фрагмента одного TaskController, просто чтобы не превращать пример в простыню. Обратите внимание: я показываю только сигнатуры, без подробной реализации, потому что это именно лекция про семантику. Детали ответа 201 Created, Location, 204 No Content и т.д. мы будем аккуратно раскладывать в следующих лекциях этого дня.
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PostMapping
ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// Контракт: создаём новый ресурс (обычно 201 Created + возможно Location)
// Реализация опущена: в лекции нам важна семантика и форма сигнатуры
throw new UnsupportedOperationException(); // заглушка для примера
}
@PutMapping("/{taskId}")
ResponseEntity<TaskDetailsResponse> replace(@PathVariable String taskId,
@Valid @RequestBody TaskPutRequest request) {
// Контракт: полная замена редактируемых полей задачи по taskId (обычно 200 OK)
throw new UnsupportedOperationException();
}
}
И отдельно — второй фрагмент того же TaskController для PATCH и DELETE, чтобы не делать один большой листинг. Заметьте, как метод (@PatchMapping vs @DeleteMapping) становится частью контракта, а не “просто аннотацией для роутинга”.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PatchMapping("/{taskId}")
ResponseEntity<TaskDetailsResponse> patch(@PathVariable String taskId,
@Valid @RequestBody TaskPatchRequest request) {
// Примечание: здесь @Valid используется так же, как и в примере выше (импорт опущен для краткости)
// Контракт: меняем только явно переданные поля (обычно 200 OK)
throw new UnsupportedOperationException();
}
@DeleteMapping("/{taskId}")
ResponseEntity<Void> delete(@PathVariable String taskId) {
// Контракт: удаляем ресурс (обычно 204 No Content)
throw new UnsupportedOperationException();
}
}
Сейчас может возникнуть вопрос: “почему нельзя сделать один update(TaskUpdateRequest) и принимать его и для PUT, и для PATCH, и вообще для всего?”. Потому что тогда контракт перестаёт быть ясным. Клиент не знает, что означает пропущенное поле. Сервер начинает делать “если поле null, то…”, потом “если поле отсутствует, то…”, потом “если клиент прислал пустой список тегов, то…”, и всё это превращается в дрейфующий набор правил, который трудно документировать и тестировать. Отдельные DTO и отдельные операции — это не бюрократия. Это способ сделать API предсказуемым.
5. Типичные ошибки при write-операциях
Ошибка №1: начинать с кода, а не с смысла.
Очень легко сесть, написать контроллер, добавить четыре аннотации, а потом уже «по ходу» решить, что такое PUT, что такое PATCH, и какой статус “лучше поставить”. Проблема в том, что клиенту всё равно, как вам удобнее: он будет жить с этим контрактом долго. Поэтому сначала вы фиксируете смысл операции, и только потом пишете код, который этот смысл выражает.
Ошибка №2: один request DTO на всё, что движется (и даже на то, что не движется).
Универсальный TaskRequest, который используется для создания, полного обновления и частичного обновления, выглядит экономно: меньше классов. Но потом выясняется, что одни поля обязательны при create, другие — при replace, а при patch вообще “можно не присылать”. В итоге DTO превращается в странное существо, где половина полей nullable “на всякий случай”, а валидация пытается угадать, что имел в виду клиент. Разделение TaskCreateRequest, TaskPutRequest, TaskPatchRequest обычно делает код больше на три файла, но уменьшает число недопониманий на порядок.
Ошибка №3: PUT, который ведёт себя как PATCH.
Самая коварная ошибка: вы объявили PUT, но если клиент не прислал поле, вы “оставляете старое”. Это кажется удобным, пока у клиента не появится второй разработчик или второй фронтенд. Один будет думать, что PUT — полная форма, второй будет думать, что PUT — “обновить что прислал”, и система начнёт жить в конфликте ожиданий. Если хотите частичное обновление — это PATCH, и у него должен быть собственный DTO и собственные правила.
Ошибка №4: PATCH, который открывает доступ ко всей внутренней модели.
Если через PATCH разрешить “менять всё подряд”, очень быстро клиент начнёт присылать поля, которые вы сами не хотели отдавать наружу. Например, createdAt или какие-то внутренние флаги. Это разрушает идею server-managed полей и ломает контракт в будущем: вы будете бояться менять внутреннюю модель, потому что “клиенты могут это присылать”. Patch-like DTO должен быть контролируемым: “вот конкретно эти поля можно менять частично”.
Ошибка №5: DELETE, который на самом деле делает другое.
Удаление легко перепутать с архивацией, сменой статуса или “мягким удалением”. Само по себе это может быть правильно бизнесово, но если вы называете это DELETE, клиент ожидает «ресурс исчезнет». Если вам нужна архивация — это отдельная договорённость и отдельная операция. В нашем курсе мы держим DELETE честным: “удалить ресурс”, с понятным успех-ответом и понятным поведением при отсутствии ресурса.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ