JavaRush /Курсы /Spring REST & MVC /Смена статуса как поведение ресурса

Смена статуса как поведение ресурса

Spring REST & MVC
25 уровень , 0 лекция
Открыта

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-код, который этому контракту соответствует.

1
Задача
Spring REST & MVC, 25 уровень, 0 лекция
Недоступна
PATCH-обновление статуса через ресурс задачи
PATCH-обновление статуса через ресурс задачи
1
Задача
Spring REST & MVC, 25 уровень, 0 лекция
Недоступна
Один URI для двух частичных изменений задачи
Один URI для двух частичных изменений задачи
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ