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-код, який цьому контракту відповідає.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ