1. Когда CRUD уже не хватает
CRUD — это отличная «база»: создать задачу, прочитать задачу, обновить задачу, удалить задачу. Но как только вы делаете что-то похожее на живой продукт, вы замечаете, что реальный мир не состоит из четырёх кнопок. Появляются правила, ограничения и «сценарии»: задачу нельзя архивировать сразу, нельзя менять архивную, нельзя «перепрыгнуть» через этапы — и вот это уже не просто update.
HTTP-семантику write-операций мы уже трогали: POST, PUT, PATCH, DELETE позволяют по-разному менять один и тот же ресурс. Теперь видно, зачем это вообще было нужно. Non-CRUD сценарии редко требуют отдельного набора «магических» endpoint’ов: обычно они раскладываются на изменение состояния самой задачи, зависимый подресурс или supporting lookup.
Чтобы было проще говорить, давайте зафиксируем термины, которые нам понадобятся. Не как экзаменационные определения, а как рабочий словарь, чтобы мы одинаково понимали, о чём речь.
| Термин | Что это в контексте REST API | Как это проявляется в Task Tracker API |
|---|---|---|
| Non-CRUD сценарий | Поведение ресурса, которое не сводится к «просто создать/прочитать/обновить/удалить», но всё равно относится к ресурсу | Смена статуса задачи по правилам, а не «как захотел клиент» |
| Состояние ресурса | Часть данных ресурса, описывающая его этап жизненного цикла | status: TODO / IN_PROGRESS / BLOCKED / DONE / ARCHIVED |
| Переход состояния | Изменение состояния, которое должно проверяться правилами (не всякая пара «текущее → новое» допустима) | Нельзя «из ARCHIVED обратно в TODO», даже если enum‑значение корректное |
| RPC‑подобный endpoint | Эндпоинт, который выглядит как команда: URI называет действие, а не ресурс | /completeTask, /startTask, /archiveTask |
Ключевая мысль этого раздела простая: non-CRUD не означает «мы отменяем REST и начинаем писать команды». Non-CRUD означает, что у ресурса есть поведение, а поведение почти всегда выражается через изменение состояния (или через появление подресурса). Здесь фокус именно на статусе как на состоянии задачи.
2. status — часть ресурса Task
Если вы когда-либо заказывали что-то в интернет-магазине, вы уже интуитивно понимаете модель статусов. Заказ не «выполняет команду becomeShipped» как магическое действие в вакууме. Он переходит из состояния «создан» в состояние «оплачен», потом «собирается», потом «отправлен». Это всё один и тот же заказ, один и тот же идентификатор — просто его состояние меняется во времени.
С задачей ровно то же самое. В нашем API status — часть представления задачи. А если поле находится в представлении ресурса, то изменение этого поля должно читаться как изменение ресурса, а не как вызов отдельной «кнопки» на сервере.
Вот самый маленький маркер того, что статус — часть ресурса: он находится в TaskDetailsResponse (то есть прямо в публичной модели ответа).
// DTO ответа: то, что видит клиент при запросе деталей задачи.
import com.example.tasktracker.domain.model.TaskStatus;
public record TaskDetailsResponse(
String id,
String title,
TaskStatus status // Текущий статус задачи — часть состояния ресурса.
) {}
Это выглядит почти слишком просто, но именно в таких «простых местах» обычно прячется фундамент: раз status — поле задачи, то операция «поменять статус» концептуально — это разновидность операции «обновить задачу».
Чтобы голова привыкла к этой модели, полезно увидеть статус как маленький конечный автомат. Ниже — намеренно грубый sketch жизненного цикла: он нужен, чтобы увидеть сам принцип. Важен не каждый конкретный переход, а факт, что статус живёт по правилам, а не как свободное поле.
stateDiagram-v2
%% Это не полная матрица переходов, а только sketch жизненного цикла.
[*] --> TODO
TODO --> IN_PROGRESS
TODO --> BLOCKED
BLOCKED --> IN_PROGRESS
IN_PROGRESS --> DONE
DONE --> ARCHIVED
ARCHIVED --> [*]
Смысл диаграммы именно в этом принципе: у статуса есть логика жизненного цикла, а значит, смена статуса — это не «просто присвоить новое значение enum», а предметное действие с правилами.
3. Action endpoints и endpoint‑зоопарк
Когда разработчик впервые сталкивается со статусами, очень хочется сделать «по-человечески»: у нас же есть действие «завершить задачу» — давайте сделаем POST /completeTask. С точки зрения первой недели разработки это даже кажется удобным: быстро, понятно, работает. А потом проходит месяц, и вы внезапно живёте среди эндпоинтов вида /startTask, /blockTask, /unblockTask, /archiveTask, /restoreTask, /reopenTaskBecauseBossSaidSo… (последний обычно появляется где-то ближе к релизу, когда дедлайны уже смотрят вам в душу).
Вот минимальный пример того самого «командного» подхода в коде контроллера:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
class TaskActionController {
@PostMapping("/completeTask") // RPC-like: путь описывает команду, а не ресурс.
void completeTask() {
// Здесь обычно прячется «кнопка процесса»: метод не показывает, какой ресурс меняется и как.
}
}
Проблема здесь не в том, что Spring не любит такие методы. Spring, как и холодильник, терпит многое. Проблема в контракте.
Во-первых, URI перестаёт быть «адресом ресурса» и превращается в «панель управления». Клиенту теперь нужно знать список команд, а не модель ресурса. Это повышает связность: вы больше не читаете API как «вот задачи, вот их свойства», вы читаете API как «вот набор кнопок, не перепутай».
Во-вторых, растёт количество эндпоинтов, и вместе с ним растёт вероятность рассинхрона. У одного action‑endpoint вы вернёте 200, у другого 204, у третьего забудете про единый ProblemDetail, у четвёртого случайно вернёте строку "OK" (а потом будете объяснять фронтенду, почему "OK" — это не JSON). Это не теория, это классический сценарий.
В-третьих, вы получаете дублирование смыслов. Например, если у вас уже есть PATCH /tasks/{id}, а потом появляется ещё и POST /tasks/{id}/changeStatus, вы создаёте два пути «поменять статус». Клиенту непонятно, какой из них канонический. Документации сложнее. Тестов больше. А ещё кто-нибудь обязательно починит баг в одном месте и забудет починить в другом.
Чтобы было совсем ясно, сравним два подхода коротко и без философии — по тому, как это ощущает клиент API:
| Вопрос клиента | RPC‑путь (action endpoints) | Ресурсный путь (update ресурса) |
|---|---|---|
| «Где находится задача?» | Где-то… но ещё есть набор команд отдельно | /api/v1/tasks/{taskId} |
| «Как поменять статус?» | «Найди правильную команду среди десятка» | «Обнови поле status у задачи» |
| «Как документировать ошибки?» | Часто получается «везде по-разному» | Легко держать единый ProblemDetail |
| «Как расширять API?» | Добавляешь новые команды (эндпоинты) | Добавляешь/уточняешь свойства и правила ресурса |
И вот тут появляется важная мысль: наличие «действий» в бизнесе не означает, что в URI должны появиться глаголы. REST не запрещает поведение. REST просит: «опиши поведение так, чтобы оно читалось как работа с ресурсом».
4. Один URI /tasks/{taskId} для статуса
Если статус — часть представления задачи, то самый естественный способ изменить статус — выполнить частичное обновление самой задачи. Мы не придумываем отдельную «команду» для каждого бизнес-глагола. Достаточно одного факта: клиент обращается всё к той же Task и меняет у неё только нужное поле.
Если клиент меняет только статус, он и отправляет только статус. Это не делает PATCH «слишком общим»: статус всё равно остаётся частью mutable‑состояния задачи, просто у этого поля есть дополнительные предметные правила. Какой бы patch‑DTO вы ни использовали, его роль здесь простая: передать только те поля, которые реально меняются.
Если перевести это в язык HTTP (как бы это увидел клиент в .http файле), то смена статуса выглядит максимально «по-ресурсному»:
# Частичное обновление конкретной задачи (ресурс остаётся тем же, меняется его состояние).
PATCH http://localhost:8080/api/v1/tasks/2f6e2d5b-1b60-4c4b-a8e7-8e2c1b7b3c11
Content-Type: application/json
Accept: application/json
{
# Меняем только статус — остальные поля не трогаем.
"status": "DONE"
}
Успешный ответ обычно возвращает обновлённое представление задачи, чтобы клиент сразу увидел новое состояние ресурса и мог отобразить его у себя без догадок. Сравните это с POST /completeTask: в action‑URI вы заранее зашиваете конкретный глагол, а PATCH /tasks/{id} сохраняет стабильный адрес ресурса и позволяет менять состояние без endpoint‑зоопарка.
5. Логика переходов: сервис и домен
Отсюда сразу вытекает граница слоёв. Контроллер принимает PATCH, но не решает, можно ли прыгнуть из TODO сразу в ARCHIVED: это предметное правило. Сервис смотрит на текущую задачу, проверяет переход и либо сохраняет новое состояние, либо отдаёт конфликт в уже существующий error layer как 409 Conflict.
Так смена статуса не выпадает из общего стиля API. URI остаётся один, web-layer — тонким, а business rules не размазываются по контроллерам и utility-endpoint’ам.
Мини‑чеклист против RPC‑хаоса
Когда вы добавляете новую возможность в API, хочется «просто сделать новый endpoint». Это нормальное человеческое желание, примерно из той же категории, что «переименовать переменную в data2 и больше никогда не возвращаться к этому месту». Но если мы хотим, чтобы API жил и развивался, полезно сделать микро‑паузу и задать себе пару вопросов.
Ниже — небольшой чеклист не в виде «буллетов на стене», а как таблица решений: она помогает быстро понять, куда вы движетесь — к ресурсной модели или к зоопарку команд.
| Вопрос, который вы себе задаёте | Если ответ «да» | Если ответ «нет» |
|---|---|---|
| Это изменение состояния существующей задачи? | Скорее всего PATCH /tasks/{id} (или PUT, если полная замена) | Возможно, вам нужен новый ресурс/подресурс |
| Это изменение можно выразить как изменение поля в представлении ресурса? | Поле в TaskPatchRequest + правила в сервисе | Возможно, это отдельный supporting ресурс |
| Вам приходится придумывать глагол в URI, чтобы объяснить операцию? | Стоп‑сигнал: ищите ресурс/состояние | Вероятно, вы ещё в ресурсной модели |
| У операции есть понятный 404/409 сценарий? | Хорошо: статус и ошибки будут предсказуемыми | Если нет — вы, возможно, делаете команду «в вакууме» |
Для смены статуса ответы получаются очень прямолинейными: это изменение состояния существующей задачи, оно выражается через поле status, и значит, основной контракт должен оставаться вокруг /tasks/{taskId}. Это и есть главный результат сегодняшней идеи.
6. Типичные ошибки при смене статуса
Ошибка №1: делать отдельный endpoint под каждый бизнес‑глагол, потому что «так понятнее».
Кажется логичным завести /startTask, /completeTask, /archiveTask, но через пару итераций вы обнаружите, что API стало трудно читать: вместо одной модели ресурса вы поддерживаете набор команд. Лечится это возвращением к вопросу «что является ресурсом и что у него меняется». Если меняется состояние задачи — остаёмся в /tasks/{taskId} и обновляем представление.
Ошибка №2: прятать смену статуса в отдельный “utility controller”, который вообще не выглядит как часть API.
Иногда появляется класс вроде TaskWorkflowController, где собираются “команды процесса”. Это почти гарантированно приведёт к тому, что ошибки и ответы начнут отличаться от основного TaskController, а формат ответов и статусов разъедется. Лучше держать контракт вокруг ресурса: задача — это задача, и её изменение идёт через TaskController, даже если внутри сервиса логика сложнее.
Ошибка №3: думать, что раз status — enum, то любой переход допустим, если JSON корректный.
Корректность JSON и корректность enum‑значения — это только “форма”. Переход — это “смысл”. Если вы разрешаете любой переход только потому, что клиент прислал валидное значение, вы ломаете бизнес‑модель и теряете смысл статусов как жизненного цикла. Правило “пара текущий→новый должна быть разрешена” — обязательная часть поведения.
Ошибка №4: возвращать 400 Bad Request на запрещённый переход статуса просто потому, что «клиент сделал неправильно».
Это тонкая, но важная ошибка. Если поле status корректно по типу и формату, но правило перехода запрещает изменение, это не «сломанный запрос», а предметный конфликт. В нашей модели такие вещи честнее выражать как 409 Conflict через единый ProblemDetail‑ответ. Так клиент может отличать «плохой JSON/валидация» от «нельзя в текущем состоянии».
Ошибка №5: пытаться решить всё названием метода контроллера и забыть, что контракт — это URI + метод + статус + тело.
Иногда разработчик думает: «ну у меня же метод называется completeTask, значит всё понятно». Но клиент вашего API не видит название Java-метода. Клиент видит HTTP: путь, метод, тело, статус, headers. Поэтому начинать нужно с контракта наружу: какой ресурс, какой URI, какой метод, какой ответ — и уже потом писать Java-код, который этому контракту соответствует.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ