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

Зміна статусу як поведінка ресурсу

Spring REST & MVC
Рівень 25 , Лекція 0
Відкрита

1. Коли CRUD уже не вистачає

CRUD — це чудова «база»: створити задачу, прочитати задачу, оновити задачу, видалити задачу. Але щойно ви починаєте робити щось схоже на реальний продукт, помічаєте: світ не складається лише з чотирьох кнопок. Зʼявляються правила, обмеження і «сценарії»: задачу не можна архівувати одразу, не можна змінювати архівну, не можна перестрибувати через етапи — і ось це вже не просто update.

Ми вже торкалися семантики write-операцій: POST, PUT, PATCH і DELETE по-різному змінюють один і той самий ресурс. Тепер видно, навіщо це взагалі було потрібно. Non-CRUD сценарії рідко потребують окремого набору «магічних» endpoint-ів: зазвичай вони зводяться до зміни стану самого ресурсу, залежного підресурсу або допоміжного довідника.

Щоб говорити було простіше, давайте зафіксуємо терміни, які нам знадобляться. Не як екзаменаційні визначення, а як робочий словник, щоб ми однаково розуміли, про що йдеться.

Термін Що це в контексті REST API Як це проявляється в Task Tracker API
Non-CRUD сценарій Поведінка ресурсу, яка не зводиться до «просто створити/прочитати/оновити/видалити», але все одно стосується ресурсу Зміна статусу задачі за правилами, а не «як захотів клієнт»
Стан ресурсу Частина даних ресурсу, що описує його етап життєвого циклу status: TODO / IN_PROGRESS / BLOCKED / DONE / ARCHIVED
Перехід стану Зміна стану, яку потрібно перевіряти правилами (не кожна пара «поточний → новий» допустима) Не можна «з ARCHIVED назад у TODO», навіть якщо enum-значення коректне
RPC-подібна кінцева точка Кінцева точка, яка виглядає як команда: 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 — поле задачі, то операція «змінити статус» концептуально є різновидом операції «оновити задачу».

Щоб звикнути до цієї моделі, корисно побачити статус як маленький скінченний автомат. Нижче — навмисно грубий ескіз життєвого циклу: він потрібен, щоб побачити сам принцип. Важливий не кожен окремий перехід, а факт, що статус живе за правилами, а не як вільне поле.

stateDiagram-v2
    %% Це не повна матриця переходів, а лише ескіз життєвого циклу.
    [*] --> TODO
    TODO --> IN_PROGRESS
    TODO --> BLOCKED
    BLOCKED --> IN_PROGRESS
    IN_PROGRESS --> DONE
    DONE --> ARCHIVED
    ARCHIVED --> [*]

Сенс діаграми саме в цьому принципі: у статусу є логіка життєвого циклу, а отже, зміна статусу — це не «просто присвоїти нове значення enum», а предметна дія з правилами.

3. Action endpoints і зоопарк кінцевих точок

Коли розробник уперше стикається зі статусами, дуже хочеться зробити «по-людськи»: у нас же є дія «завершити задачу» — давайте зробимо 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 як «ось набір кнопок, не переплутай».

По-друге, зростає кількість кінцевих точок, і разом із нею зростає ймовірність розсинхрону. В одній такій кінцевій точці ви повернете 200, в іншій — 204, у третій забудете про єдиний ProblemDetail, у четвертій випадково повернете рядок "OK" (а потім будете пояснювати фронтенду, чому "OK" — це не JSON). Це не теорія, це класичний сценарій.

По-третє, ви отримуєте дублювання смислів. Наприклад, якщо у вас уже є PATCH /tasks/{id}, а потім зʼявляється ще й POST /tasks/{id}/changeStatus, ви створюєте два шляхи «змінити статус». Клієнту незрозуміло, який із них канонічний. Документацію складніше підтримувати. Тестів більше. А ще хтось обов’язково виправить помилку в одному місці й забуде виправити в іншому.

Щоб було зовсім ясно, порівняймо два підходи коротко і без філософії — за тим, як це відчуває клієнт API:

Запитання клієнта RPC-шлях (action endpoints) Ресурсний шлях (оновлення ресурсу)
«Де знаходиться задача?» Десь… але ще є окремий набір команд /api/v1/tasks/{taskId}
«Як змінити статус?» «Знайди правильну команду серед десятка» «Онови поле status у задачі»
«Як документувати помилки?» Часто виходить «усюди по-різному» Легко тримати єдиний ProblemDetail
«Як розширювати API?» Додаєте нові команди (кінцеві точки) Додаєте або уточнюєте властивості та правила ресурсу

І ось тут зʼявляється важлива думка: наявність «дій» у бізнесі не означає, що в URI мають зʼявитися дієслова. REST не забороняє поведінку. REST просить: «опишіть поведінку так, щоб вона читалася як робота з ресурсом».

4. Один URI /tasks/{taskId} для статусу

Якщо статус — частина представлення задачі, то найприродніший спосіб змінити статус — виконати часткове оновлення самої задачі. Ми не вигадуємо окрему «команду» для кожного бізнес-дієслова. Достатньо одного факту: клієнт звертається все до тієї самої Task і змінює в ній лише потрібне поле.

Якщо клієнт змінює тільки статус, він і надсилає тільки статус. Це не робить PATCH «занадто загальним»: статус усе одно залишається частиною змінюваного стану задачі, просто це поле має додаткові предметні правила. Який би 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: це предметне правило. Сервіс дивиться на поточну задачу, перевіряє перехід і або зберігає новий стан, або повертає конфлікт у вже наявний шар обробки помилок у вигляді 409 Conflict.

Так зміна статусу не випадає із загального стилю API. URI залишається один, вебшар — тонкий, а бізнес-правила не розмазуються по контролерах і допоміжних кінцевих точках.

Мінічекліст проти RPC-хаосу

Коли ви додаєте нову можливість в API, хочеться «просто зробити нову кінцеву точку». Це нормальне людське бажання, приблизно з тієї самої категорії, що й «перейменувати змінну на data2 і більше ніколи не повертатися до цього місця». Але якщо ми хочемо, щоб API жив і розвивався, корисно зробити мікропаузу і поставити собі кілька запитань.

Нижче — невеликий чекліст не у вигляді «булетів на стіні», а як таблиця рішень: вона допомагає швидко зрозуміти, куди ви рухаєтесь — до ресурсної моделі чи до зоопарку команд.

Запитання, яке ви собі ставите Якщо відповідь «так» Якщо відповідь «ні»
Це зміна стану наявної задачі? Найімовірніше PATCH /tasks/{id} (або PUT, якщо повна заміна) Можливо, вам потрібен новий ресурс або підресурс
Цю зміну можна виразити як зміну поля у представленні ресурсу? Поле у TaskPatchRequest + правила в сервісі Можливо, це окремий допоміжний ресурс
Вам доводиться вигадувати дієслово в URI, щоб пояснити операцію? Стоп-сигнал: шукайте ресурс або стан Ймовірно, ви ще в ресурсній моделі
У операції є зрозумілий сценарій 404/409? Добре: статус і помилки будуть передбачуваними Якщо ні — ви, можливо, робите команду «у вакуумі»

Для зміни статусу все дуже прямолінійно: це зміна стану наявної задачі, вона виражається через поле status, а отже, основний контракт має залишатися навколо /tasks/{taskId}. Це і є головний висновок сьогоднішньої ідеї.

6. Типові помилки під час зміни статусу

Помилка №1: робити окрему кінцеву точку під кожне бізнес-дієслово, бо «так зрозуміліше».
Здається логічним завести /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: шлях, метод, тіло, статус, заголовки. Тому починати потрібно з контракту назовні: який ресурс, який URI, який метод, яка відповідь — і вже потім писати Java-код, який цьому контракту відповідає.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ