1. Роль PUT рядом с PATCH
PUT часто пытаются использовать как универсальную “обновлялку”: то пару полей поменять, то всю задачу целиком перезаписать. Но у REST-контракта слабое чувство юмора: если вы объявили PUT, вы по сути сказали клиенту “пришли мне полное новое состояние редактируемой части ресурса”. Это похоже на замену анкеты целиком, а не на “дописать два слова в поле ‘о себе’”.
Чтобы не путаться, полезно держать в голове короткую мысль: PUT — это replace, PATCH — это change what you explicitly sent. В нашем учебном проекте это особенно важно, потому что мы хотим, чтобы API читался предсказуемо: один метод — одно обещание.
Ниже — маленькая таблица, чтобы закрепить различие на уровне контракта (не на уровне “как в коде удобнее”):
| Метод | URI | Что обещаем клиенту | Как обычно выглядит request DTO |
|---|---|---|---|
| PUT | /api/v1/tasks/{taskId} | Полная замена редактируемых полей | “полная” модель: почти все mutable поля |
| PATCH | /api/v1/tasks/{taskId} | Частичное изменение только пришедших полей | “частичная” модель: nullable поля, которые можно прислать выборочно |
2. Контракт PUT в Task Tracker API
Когда говорят “реализовать PUT”, новичок часто слышит: “ну, напиши метод контроллера и сделай task.setTitle(...)”. В реальности PUT — это договорённость из нескольких частей: какой URI, какой request DTO, какой response DTO, какой статус, какие ошибки и что именно считается “полной заменой” в нашем домене. Если контракт расплывчатый, клиент будет угадывать — и угадает не так, как вы хотели.
В нашем проекте мы делаем простое, но честное решение: PUT /api/v1/tasks/{taskId} обновляет существующую задачу. Если задачи нет — возвращаем 404 Not Found (мы не делаем “upsert через PUT”, и это осознанно). Если вход невалидный — возвращаем 400 Bad Request в формате ProblemDetail. Если задача в состоянии, где правки запрещены бизнес-правилом (например, ARCHIVED) — возвращаем 409 Conflict с нашим error code.
Для “ощущения контракта руками” полезно иметь пример запроса. В .http файле это будет выглядеть так:
# Полная замена редактируемых полей задачи (replace)
PUT http://localhost:8080/api/v1/tasks/8f3e2f2c-0a2b-4d4f-9a5a-1b2c3d4e5f60
Content-Type: application/json
Accept: application/json
{
"title": "Подготовить демо для клиента",
"description": "Показать фильтрацию и сортировку",
"status": "IN_PROGRESS",
"priority": "HIGH",
"dueDate": "2026-04-01",
"assigneeName": "Alice",
"tags": ["demo", "backend"]
}
Успешный ответ обычно будет 200 OK и тело с актуальным TaskDetailsResponse. Да, можно было бы сделать 204 No Content, но для учебного проекта и для удобства клиента 200 с телом — очень практичный вариант: клиент сразу видит “как сервер понял мои данные”.
3. TaskPutRequest и редактируемые поля
Понять PUT проще всего через вопрос: “какие поля задачи редактируемые?”. В нашей модели есть поля, которыми управляет клиент (title, description, priority, dueDate, assigneeName, tags и в рамках этой write-семантики — status), и есть поля, которыми управляет только сервер (id, createdAt, updatedAt). Здесь status пока важен как часть replace-контракта: клиент может прислать новое значение вместе с остальными редактируемыми данными. Какие переходы между статусами вообще допустимы — это уже отдельный бизнес-вопрос над тем же ресурсом, а не различие между PUT и PATCH.
Полная замена касается только редактируемой части, а server-managed поля мы не принимаем из JSON вообще — чтобы не создавать соблазна “а давайте клиент пришлёт updatedAt”.
Сам request DTO для PUT должен быть “полным” в том смысле, что он содержит весь набор редактируемых полей. Это не значит, что каждое поле обязательно non-null. Например, description может быть опциональным: прислал null — значит “очистить описание”. Но обязательные по смыслу поля (title, status, priority, tags) мы делаем обязательными через validation, иначе PUT начнёт незаметно вести себя как “частичное обновление”.
Вот пример TaskPutRequest в стиле нашего проекта (record, Bean Validation, без server-managed полей):
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
/**
* Request DTO для PUT (replace).
* Важно: здесь только редактируемые поля, никаких id/createdAt/updatedAt.
*/
public record TaskPutRequest(
// Обязательное поле: пустую строку не принимаем
@NotBlank
@Size(min = 3, max = 120)
String title,
// Опциональное поле: null означает "очистить описание"
@Size(max = 2000)
String description,
// Обязательные поля доменной модели для полного payload'а
@NotNull
TaskStatus status,
@NotNull
TaskPriority priority,
// Опционально: отсутствие/ null = "дедлайна нет"
LocalDate dueDate,
// Опционально: можно не назначать исполнителя
@Size(max = 80)
String assigneeName,
// Обязательное поле: если тегов нет — присылай []
@NotNull
@Size(max = 10)
List<@Size(min = 1, max = 30) String> tags
) {
}
Обратите внимание на важный момент: tags здесь @NotNull. Это прямое сообщение клиенту: “если тегов нет — пришли пустой список [], но не пропускай поле”. Это помогает удержать семантику полной замены и не превращать PUT в полу-PATCH по привычке.
4. Контроллер: @PutMapping
Контроллеру здесь не нужно быть умнее контракта: он принимает taskId, валидирует полный TaskPutRequest, делегирует в сервис и возвращает 200 OK с актуальным TaskDetailsResponse. Если контроллер начинает сам решать, какие поля менять, PUT быстро расползается в полу-PATCH.
Минимальный пример метода (внутри TaskController с базовым @RequestMapping("/api/v1/tasks")) может быть таким:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.service.TaskWriteService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskWriteService taskWriteService;
public TaskController(TaskWriteService taskWriteService) {
// Внедряем сервис, который реализует именно "replace", а не "partial update"
this.taskWriteService = taskWriteService;
}
@PutMapping("/{taskId}")
public ResponseEntity<TaskDetailsResponse> replace(
// Идентификатор берём из пути: PUT адресует конкретный ресурс
@PathVariable String taskId,
// Валидация обязательных полей для семантики полной замены
@Valid @RequestBody TaskPutRequest request) {
// Сервис реализует контракт: 404 если нет, 409 если нельзя, replace если можно
TaskDetailsResponse updated = taskWriteService.replace(taskId, request);
return ResponseEntity.ok(updated);
}
}
Всё. Никакой магии. Вся “соль” PUT — не в аннотации, а в том, что сервис сделает именно replace, а не “обновлю только то, что не null”.
5. Сервис replace(): честный PUT
В сервисе и живёт главное обещание PUT: найти задачу, проверить бизнес-ограничения и заменить редактируемые поля целиком. По шагам здесь всё просто: нет задачи — это 404, запрет на редактирование — это 409, всё остальное — честный replace. Именно здесь нельзя скатиться в логику вида if(field != null), потому что это уже был бы PATCH.
Пример сервисного интерфейса (чтобы было понятно, что это отдельное намерение replace):
package com.example.tasktracker.domain.service;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
/**
* Write-сервис с явной операцией replace для семантики PUT.
*/
public interface TaskWriteService {
/**
* Полностью заменяет редактируемые поля задачи.
* @param taskId идентификатор задачи из path variable
* @param request полный payload для замены редактируемых полей
* @return актуальное представление задачи после сохранения
*/
TaskDetailsResponse replace(String taskId, TaskPutRequest request);
}
Теперь пример реализации. Я специально делаю его максимально “прямым”, без выноса в сто слоёв, чтобы новичку было легко читать:
package com.example.tasktracker.domain.service;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.mapper.TaskMapper;
import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.exception.TaskUpdateNotAllowedException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
import com.example.tasktracker.infrastructure.repository.TaskRepository;
import java.time.Instant;
import org.springframework.stereotype.Service;
@Service
public class DefaultTaskWriteService implements TaskWriteService {
private final TaskRepository taskRepository;
private final TaskMapper taskMapper;
public DefaultTaskWriteService(TaskRepository taskRepository, TaskMapper taskMapper) {
this.taskRepository = taskRepository;
this.taskMapper = taskMapper;
}
@Override
public TaskDetailsResponse replace(String taskId, TaskPutRequest request) {
// 1) Находим задачу. Нет задачи -> доменное исключение -> 404 Not Found
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
// 2) Проверяем бизнес-ограничения. Например, архивную задачу менять нельзя -> 409 Conflict
if (task.getStatus() == TaskStatus.ARCHIVED) {
throw new TaskUpdateNotAllowedException(taskId); // 409 Conflict
}
// 3) Полностью заменяем редактируемые поля (не "проверяем на null", а присваиваем как есть)
taskMapper.applyPut(task, request);
// 4) Server-managed поля выставляем на стороне сервера
task.setUpdatedAt(Instant.now());
// 5) Сохраняем и возвращаем response DTO
Task saved = taskRepository.save(task);
return taskMapper.toDetailsResponse(saved);
}
}
Да, тут есть нюанс про updatedAt и идемпотентность. Формально повторный PUT с тем же телом не должен “накручивать” состояние. На практике многие API всё равно обновляют updatedAt, потому что запрос был, и ресурс как минимум “переустановили”. Для учебного проекта это допустимо, но важно понимать, что вы делаете и почему.
6. null в PUT: правила замены
На уровне слов всё звучит просто: “в PUT клиент присылает всё”. Но на уровне JSON есть одна неприятная реальность: JSON не отличает “поле отсутствует” и “поле присутствует, но null” так, как вам хочется, если вы не договорились об этом заранее. Jackson, когда видит отсутствующее поле, просто оставляет значение null (для ссылочных типов). И вот тут у новичка появляется соблазн: “ну раз в DTO null, значит не будем менять поле”. А это уже превращение PUT в PATCH.
Правило, которое спасает от этой путаницы, звучит скучно, но работает: PUT-DTO должен быть устроен так, чтобы обязательные поля не могли быть null (validation вам поможет), а опциональные поля должны явно поддерживать null как значение “очистить”. Тогда поведение становится предсказуемым: если клиент “забыл” поле — он либо получит 400 (если поле обязательное), либо действительно очистит поле (если оно опциональное). И да, это иногда больно. Но боль — отличный учитель API-дисциплины.
Чтобы почувствовать разницу, сравните два запроса. В первом клиент явно очищает description:
{
"title": "Подготовить демо для клиента",
"description": null,
"status": "IN_PROGRESS",
"priority": "HIGH",
"dueDate": "2026-04-01",
"assigneeName": "Alice",
"tags": ["demo", "backend"]
}
Во втором клиент просто не прислал description вообще:
{
"title": "Подготовить демо для клиента",
"status": "IN_PROGRESS",
"priority": "HIGH",
"dueDate": "2026-04-01",
"assigneeName": "Alice",
"tags": ["demo", "backend"]
}
Для PUT эти два варианта в нашем упрощённом контракте ведут себя одинаково: description станет null. Если вам нужно различать “не прислал” и “прислал null”, это уже территория более сложных контрактов и отдельной темы. В рамках курса мы держим PUT простым и честным: “не прислал — значит заменил на null”.
7. Ошибки, статусы и ProblemDetail
Когда у вас уже есть централизованный error handling, PUT становится спокойнее: вы не пишете try/catch в контроллере, не возвращаете “строку ошибки”, а просто выбрасываете правильные исключения на правильном уровне. Spring MVC + @ControllerAdvice превращают это в ProblemDetail согласно нашему контракту.
Важно различать три базовых класса ошибок именно для PUT. Невалидный вход (title пустой, tags = null, priority не задан) — это 400 Bad Request и код вроде INVALID_INPUT. Отсутствующий ресурс — это 404 Not Found и код TASK_NOT_FOUND. Предметное ограничение (“архивную задачу редактировать нельзя”) — это 409 Conflict с отдельным кодом, например TASK_UPDATE_NOT_ALLOWED.
Пример “в духе проекта”, как может выглядеть ProblemDetail для not found (сильно сокращённо, чтобы не утонуть в полях):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Task not found: 8f3e2f2c-0a2b-4d4f-9a5a-1b2c3d4e5f60",
"instance": "/api/v1/tasks/8f3e2f2c-0a2b-4d4f-9a5a-1b2c3d4e5f60",
"code": "TASK_NOT_FOUND"
}
А вот для validation failure вы, скорее всего, увидите 400 и дополнительные детали (например, fieldErrors), которые мы уже договорились вкладывать в ProblemDetail как расширение контракта. Главное, что клиент теперь не должен гадать: он видит единый формат ошибок во всех write-операциях.
Рефакторинг: applyPut() в mapper’е
Когда вы пишете replace() в сервисе “в лоб”, очень быстро появляется длинная цепочка task.setX(request.x()). Для одной задачи это терпимо. Для нескольких ресурсов это превращается в “обряд копипаста”, который сложно поддерживать. Но мы и не хотим прятать логику в магию: всё равно нужен явный, читаемый маппинг.
Компромисс, который хорошо ложится в учебный проект, — вынести именно “присвоение полей” в mapper, оставив сервису orchestration: найти задачу, проверить правила, применить изменения, сохранить. Пример applyPut() может выглядеть так:
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
@Component
public class TaskMapper {
/**
* Механически копирует поля из PUT-request в доменную модель.
* Важно: без бизнес-логики и без "если null — оставим старое".
*/
public void applyPut(Task task, TaskPutRequest request) {
// Полная замена редактируемых полей
task.setTitle(request.title());
task.setDescription(request.description());
task.setStatus(request.status());
task.setPriority(request.priority());
task.setDueDate(request.dueDate());
task.setAssigneeName(request.assigneeName());
task.setTags(request.tags());
}
// toDetailsResponse(...) уже существует с прошлых дней
}
Этот метод не должен становиться “умным” и начинать валидировать бизнес-правила. Его роль — прозрачное, механическое копирование данных. Тогда код остаётся читаемым: сервис отвечает за смысл операции, mapper — за трансляцию формы данных.
8. Типичные ошибки при реализации PUT
Ошибка №1: PUT ведёт себя как PATCH (“если null — не меняем”).
Это самая популярная попытка “сделать удобно”. Но удобство тут ложное: вы ломаете контракт метода. Клиент начинает отправлять неполные payload’ы, и потом вы уже не сможете объяснить, чем PUT отличается от PATCH. Если вы выбрали PUT, применяйте значения как есть, а частичные изменения оставляйте для PATCH.
Ошибка №2: в TaskPutRequest добавляют id, createdAt, updatedAt.
Это почти всегда происходит из желания “сделать DTO похожим на response”. Итог печальный: вы даёте клиенту иллюзию управления server-managed полями. Даже если вы “просто игнорируете” эти значения, вы создаёте шум в контракте и соблазн для злоупотреблений. Проще и честнее: этих полей в request DTO не должно быть.
Ошибка №3: PUT на коллекцию (PUT /api/v1/tasks).
Иногда это делается “по аналогии с POST”, но смысл теряется: PUT адресует конкретный ресурс. Если вы хотите “заменить весь список задач” — это вообще отдельная, редкая операция, и она точно не часть нашего курса. В нашем проекте PUT всегда работает с /tasks/{taskId}.
Ошибка №4: “upsert через PUT” без договорённости (если нет — создадим).
В некоторых системах так действительно делают, но это сложнее, чем кажется: появляются вопросы про генерацию id, про статус ответа (201 или 200), про Location, про идемпотентность на создании. В этом курсе мы сознательно держим контракт простым: нет ресурса — 404. Это легче объяснить, легче документировать и легче тестировать.
Ошибка №5: tags делают nullable, а потом в коде “подлечивают” null в пустой список.
На первый взгляд кажется безобидным: “ну что такого, если клиент не прислал tags”. Но это опять превращает PUT в “частично-полный” контракт и ломает предсказуемость. Если поле обязательно для полного payload’а, пусть validation честно отстреливает null с 400. Клиент быстро научится присылать [].
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ