1. Коректний enum vs дозволений перехід
Якщо раніше статус завдання здавався просто одним із полів, то на практиці він поводиться як мініжиттєвий цикл. Завдання має стан, і він має змінюватися за правилами, а не «як клієнту заманулося цього вівторка». Інакше API перетворюється на портал телепортації: сьогодні завдання було TODO, завтра — ARCHIVED, а післязавтра — «чому звіт не зійшовся».
Одного лише правильного URI тут недостатньо. Поки не зафіксовано повний набір статусів і дозволені пари переходів, PATCH лишається красивою ідеєю без чітких правил.
Ключова думка лекції проста: клієнт може надіслати коректне значення TaskStatus (тобто JSON нормально розпарсився, enum існує, Bean Validation не повідомила про помилку), але перехід усе одно може бути заборонений предметною логікою. Це вже не «поганий вхід», а конфлікт із правилами домену. У HTTP-термінах це найчастіше 409 Conflict.
Щоб відчути різницю, уявіть систему доставки. Статус «ДОСТАВЛЕНО» — валідний. Але якщо посилка зараз «ЗІБРАНО НА СКЛАДІ», прямий стрибок у «ДОСТАВЛЕНО» виглядає як магія. У реальному бізнесі магія заборонена, якщо не рахувати магію в Spring — але це вже інша лекція.
2. Контракт PATCH /api/v1/tasks/{taskId}
З погляду зовнішнього API ми не вводимо новий endpoint на кшталт /changeStatus. Ми залишаємося в межах ресурсу Task і говоримо: статус — це частина змінюваного стану завдання, а отже змінюємо його через PATCH /tasks/{taskId} тим самим механізмом, що й інші часткові зміни.
Запит виглядає максимально «по-людськи»: надсилаємо лише те, що хочемо змінити. Якщо хочемо змінити лише статус — надсилаємо лише статус. Якщо хочемо змінити і опис, і статус — надсилаємо обидва поля. Жодних окремих «командних» шляхів.
Приклад .http запиту (умовний UUID підставте з початкових даних):
PATCH http://localhost:8080/api/v1/tasks/7d3d7d9a-1b2c-4e62-9c65-7b3d2a6b0f11
Content-Type: application/json
Accept: application/json
{
"status": "IN_PROGRESS"
}
У разі успіху сервер повертає 200 OK і оновлене представлення завдання — зазвичай TaskDetailsResponse, щоб клієнт бачив, що саме сталося зі статусом.
А ось якщо клієнт надішле формально коректний статус, але перехід заборонений, ми повертаємо 409 Conflict у форматі application/problem+json (через ProblemDetail) і з нашим code = INVALID_STATUS_TRANSITION.
Сама модель TaskPatchRequest при цьому лишається звичайним DTO: вона описує форму входу, але не гарантує, що «все можна». DTO — про структуру, сервіс — про зміст.
3. Матриця переходів статусів
Коли ми говоримо «дозволені переходи», дуже хочеться заховати це десь у глибині коду: парою if в одному місці, парою if в іншому… і через тиждень виявити, що DONE -> TODO десь таки просочився. Тому нам потрібна матриця переходів: одне явне місце, де правила можна прочитати очима.
Нижче — повний робочий набір статусів і дозволених переходів для проєкту.
У нашому Task Tracker API правила такі — вони вже зафіксовані в ТЗ проєкту:
| Поточний статус | Дозволені наступні статуси |
|---|---|
| TODO | IN_PROGRESS, BLOCKED |
| IN_PROGRESS | BLOCKED, DONE |
| BLOCKED | IN_PROGRESS, DONE |
| DONE | ARCHIVED |
| ARCHIVED | (немає переходів) |
Можна уявити це як мініавтомат станів — без BPMN, workflow engine та іншого «усе, я пішов у відпустку на два тижні»:
stateDiagram-v2
TODO --> IN_PROGRESS
TODO --> BLOCKED
IN_PROGRESS --> BLOCKED
IN_PROGRESS --> DONE
BLOCKED --> IN_PROGRESS
BLOCKED --> DONE
DONE --> ARCHIVED
Тут важлива ідея: ARCHIVED — фінальна зупинка. Із нього «назад» ми не йдемо. Це не тому, що REST забороняє, а тому, що бізнес зазвичай забороняє. Архів — це як «переїхало в /dev/null, але культурно».
Ще один нюанс: що робити, якщо клієнт надіслав той самий статус, який уже встановлений у завданні? На практиці це зручніше трактувати як «нічого не змінюємо», тобто не вважати конфліктом. Це робить поведінку більш терпимою до повторних викликів і до зайвих PATCH, які іноді роблять клієнти.
4. Перевірка переходу в сервісі
Зміна статусу виглядає як маленька зміна JSON, і новачок часто робить логічний стрибок: «раз це в PATCH, значить перевірку можна написати в контролері». Це дуже спокусливо, тому що контролер поруч, рука тягнеться… але так ми швидко отримаємо надто «товстий» контролер і втратимо головний плюс нашої архітектури: контролер — про HTTP, сервіс — про домен.
Перевірка переходу — це предметне правило, а не web-механіка. Воно не залежить від @RequestBody, ResponseEntity або Accept. Воно залежить від поточного стану завдання і бажаного наступного стану. Отже, логічно, щоб воно жило в доменному сервісі або поруч із ним в окремому компоненті правил, а назовні виражалося доменним винятком.
Приблизний потік виходить таким: контролер отримує taskId і TaskPatchRequest, передає їх у сервіс, а сервіс або повертає оновлене завдання, або кидає виняток InvalidStatusTransitionException. І вже глобальний шар обробки помилок перетворює це на ProblemDetail із 409 Conflict.
Контролер при цьому може залишатися майже «нудним» — і це комплімент:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
// Сервіс — місце, де живе доменна логіка, зокрема правила зміни статусу.
private final TaskService taskService;
TaskController(TaskService taskService) {
// Впроваджуємо залежність. Контролер сам нічого не «вирішує» щодо переходів.
this.taskService = taskService;
}
@PatchMapping("/{taskId}")
TaskDetailsResponse patch(@PathVariable String taskId,
@RequestBody TaskPatchRequest request) {
// Контролер займається лише входом і виходом HTTP, усе інше — у сервісі.
return taskService.patchTask(taskId, request);
}
}
Так, тут немає ResponseEntity. Це нормально: статус 200 нас влаштовує, а помилки все одно буде оброблено централізовано.
5. Реалізація isAllowed для переходів
Самі правила переходів можна реалізувати різними способами. У навчальному проєкті ми обираємо те, що легше читати й пояснювати. І так, іноді це не «найбільш абстрактне і розширюване», зате найзрозуміліше. Зрештою, ми будуємо API, а не бібліотеку «універсальний рушій усіх статусів світу».
Варіант 1: switch за поточним статусом
Це найпряміший, чесний і читабельний варіант. Його добре видно очима, і він ідеально підходить, коли станів небагато.
import com.example.tasktracker.domain.model.TaskStatus;
final class TaskStatusTransitions {
boolean isAllowed(TaskStatus current, TaskStatus next) {
// Перевіряємо перехід саме з current у next.
// Важливо: коректність enum уже «вирішена» на рівні десеріалізації, тут — доменне правило.
return switch (current) {
case TODO -> next == TaskStatus.IN_PROGRESS || next == TaskStatus.BLOCKED;
case IN_PROGRESS -> next == TaskStatus.BLOCKED || next == TaskStatus.DONE;
case BLOCKED -> next == TaskStatus.IN_PROGRESS || next == TaskStatus.DONE;
case DONE -> next == TaskStatus.ARCHIVED;
// Фінальний статус: із архіву вихід заборонений.
case ARCHIVED -> false;
};
}
}
Зверніть увагу на важливу річ: тут ми порівнюємо пару current -> next. Нам не важливо, що next «існує як enum». Нам важливо, чи можна туди переходити саме з поточного стану.
Варіант 2: таблиця через Map<TaskStatus, Set<TaskStatus>>
Іноді хочеться зберігати правила як структуру даних, щоб вона виглядала «як конфіг». У невеликій системі це теж нормально, просто не треба робити з цього «фреймворк переходів».
import com.example.tasktracker.domain.model.TaskStatus;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
final class TaskStatusTransitions {
// EnumMap/EnumSet — швидкий і компактний спосіб зберігати правила для enum.
private final Map<TaskStatus, Set<TaskStatus>> rules = new EnumMap<>(TaskStatus.class);
TaskStatusTransitions() {
// Явно фіксуємо матрицю переходів в одному місці.
rules.put(TaskStatus.TODO, EnumSet.of(TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED));
rules.put(TaskStatus.IN_PROGRESS, EnumSet.of(TaskStatus.BLOCKED, TaskStatus.DONE));
rules.put(TaskStatus.BLOCKED, EnumSet.of(TaskStatus.IN_PROGRESS, TaskStatus.DONE));
rules.put(TaskStatus.DONE, EnumSet.of(TaskStatus.ARCHIVED));
// ARCHIVED — фінальний статус: доступних наступних статусів немає.
rules.put(TaskStatus.ARCHIVED, EnumSet.noneOf(TaskStatus.class));
}
}
Цей варіант хороший тим, що таблиця «читається як матриця». Але мінус теж чесний: новачкам трохи складніше через EnumMap, EnumSet і конструктор. Тому в навчальному контексті частіше перемагає switch.
Для нашої лекції достатньо обрати один шлях — я б узяв switch. Але важливо розуміти, що сенс не в синтаксисі, а в тому, що правило має бути єдиним, явним і легко перевірюваним.
6. Перевірка переходу в patchTask
Тепер найважливіше: як зробити так, щоб PATCH справді застосовував правило переходу, а не просто робив task.setStatus(request.status()) «якщо поле не null».
Хороший сервісний алгоритм зазвичай виглядає так: ми отримуємо завдання, перевіряємо, що воно існує, потім перевіряємо правило переходу, якщо статус у запиті присутній, і лише після цього змінюємо стан. Це важливо саме в такому порядку: спочатку перевірка, потім зміни. Інакше ви отримаєте «змінили — і тут же зрозуміли, що не можна», і почнеться хаос рівня «а що в пам’яті, а що в репозиторії, а що у відповіді».
DTO для PATCH лишається простим:
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.model.TaskStatus;
public record TaskPatchRequest(
// null означає «поле не передали, змінювати не треба»
String title,
// null означає «поле не передали, змінювати не треба»
String description,
// null означає «статус не змінюємо»
TaskStatus status
) {}
А сервісний метод, якщо спростити, може виглядати так:
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;
import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
class DefaultTaskService {
private final TaskRepository taskRepository;
// Правила переходів тримаємо окремо, щоб не «розмазувати» if-и по сервісу.
private final TaskStatusTransitions transitions = new TaskStatusTransitions();
Task patchTask(String taskId, TaskPatchRequest request) {
// 1) Спочатку дістаємо завдання або чесно падаємо з логікою 404 через виняток.
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
// 2) Якщо статус у PATCH є — перевіряємо доменне правило переходу.
TaskStatus next = request.status();
if (next != null && next != task.getStatus() && !transitions.isAllowed(task.getStatus(), next)) {
// Важливо: кидаємо виняток ДО будь-яких змін стану.
throw new InvalidStatusTransitionException(task.getStatus(), next);
}
// 3) Після перевірки можна безпечно змінювати стан.
if (next != null && next != task.getStatus()) {
task.setStatus(next);
}
// 4) Зберігаємо вже коректний стан.
return taskRepository.save(task);
}
}
Так, рядок з if (...) виглядає задовгим, але в ньому є вся суть: ми розрізняємо «поле не передали» і «поле передали», а також «нічого не змінюємо» і «справді намагаємося перейти».
У реальному проєкті ви, звісно, ще оновлюєте title і description, змінюєте updatedAt, перевіряєте, що архівне завдання не можна редагувати, і так далі. Але для сьогоднішньої теми нам критично саме місце перевірки переходу.
7. 409 Conflict і ProblemDetail
Дуже легко переплутати два схожі сценарії.
Перший сценарій: клієнт надіслав "status": "IN_PROGRES" (описка). Jackson не може розпарсити значення в enum. Це помилка входу, і вона має піти в 400 Bad Request (у вас це вже покрито глобальним обробником як типовою помилкою десеріалізації на рівні фреймворка).
Другий сценарій: клієнт надіслав "status": "ARCHIVED", і Jackson чудово розпарсив його в TaskStatus.ARCHIVED. Але завдання зараз TODO, а TODO -> ARCHIVED заборонений. Це вже не «пошкоджений JSON» і не «невалідний enum». Це конфлікт із правилами домену. Тут чесно відповідати 409 Conflict.
Ось як може виглядати наш ProblemDetail — приблизний JSON:
{
"type": "about:blank",
"title": "Неприпустимий перехід статусу",
"status": 409,
"detail": "Перехід TODO -> ARCHIVED не дозволений.",
"instance": "/api/v1/tasks/7d3d7d9a-1b2c-4e62-9c65-7b3d2a6b0f11",
"code": "INVALID_STATUS_TRANSITION",
"currentStatus": "TODO",
"nextStatus": "ARCHIVED"
}
Зверніть увагу: клієнту корисно знати не лише «не можна», а й чому саме не можна. Тому ми додаємо в problem response деталі: поточний і запитаний статус. Це ідеально підходить до розширюваності ProblemDetail: стандартні поля залишаються стандартними, а специфічні дані кладемо як додаткові properties.
8. InvalidStatusTransitionException
Тепер ми поєднаємо дві частини: доменний виняток і його перетворення на ProblemDetail. Це той момент, коли «бізнес-логіка» стає «контрактом API»: сервіс каже «не можна», а зовнішній світ отримує узгоджену помилкову відповідь із потрібним статусом.
Доменний виняток
Виняток має жити в доменному шарі й не залежати від Spring MVC. Він просто описує проблему.
package com.example.tasktracker.domain.exception;
import com.example.tasktracker.domain.model.TaskStatus;
public class InvalidStatusTransitionException extends RuntimeException {
// Поточний статус завдання на момент спроби переходу.
private final TaskStatus current;
// Статус, у який намагалися перейти.
private final TaskStatus next;
public InvalidStatusTransitionException(TaskStatus current, TaskStatus next) {
// Повідомлення зручне для логів і для detail у ProblemDetail.
super("Перехід " + current + " -> " + next + " не дозволений.");
this.current = current;
this.next = next;
}
public TaskStatus getCurrent() { return current; }
public TaskStatus getNext() { return next; }
}
Цей виняток не вирішує, яким буде HTTP-статус. Він лише констатує факт: перехід заборонено.
Обробник у @ControllerAdvice
У GlobalExceptionHandler додаємо @ExceptionHandler. Ми створюємо ProblemDetail зі статусом 409, встановлюємо title, прокидаємо code і додаємо пару властивостей.
import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
class GlobalExceptionHandler {
@ExceptionHandler(InvalidStatusTransitionException.class)
ProblemDetail handleInvalidTransition(InvalidStatusTransitionException ex) {
// 409 — це «конфлікт» із поточним станом ресурсу за правилами домену.
ProblemDetail problem = ProblemDetail.forStatus(409);
// Коротка назва помилки, зрозуміла людині.
problem.setTitle("Неприпустимий перехід статусу");
// Detail — зазвичай конкретика щодо переходу (можна показувати клієнту).
problem.setDetail(ex.getMessage());
// Машинозчитуваний code для фронтенду й інтеграцій.
problem.setProperty("code", "INVALID_STATUS_TRANSITION");
// Додаткові поля допомагають клієнту зрозуміти, що саме не збіглося.
problem.setProperty("currentStatus", ex.getCurrent().name());
problem.setProperty("nextStatus", ex.getNext().name());
return problem;
}
}
Так, тут ми повертаємо ProblemDetail прямо з обробника. Це нормальний і дуже читабельний шлях. Якщо ваш GlobalExceptionHandler наслідує ResponseEntityExceptionHandler, це теж не заважає: метод із @ExceptionHandler живе поруч.
9. Типові помилки зміни статусу в PATCH
Помилка № 1: повертати 400 Bad Request на заборонений перехід.
Це найчастіша плутанина. 400 означає, що вхід «зламався» вже на рівні формату, структури або базових обмежень. Але заборонений перехід — це ситуація «вхід зрозумілий, але правила забороняють такий перехід». Якщо ви віддаєте 400, клієнту стає неможливо відрізнити «я надіслав сміття» від «я надіслав нормальні дані, але бізнес сказав ні». Виправлення просте: на invalid transition — 409 Conflict з окремим code.
Помилка № 2: тримати матрицю переходів у контролері.
Контролер має думати про HTTP: як прийняти body, як віддати відповідь, які заголовки поставити. Щойно ви розміщуєте в ньому матрицю переходів, ви перетворюєте web-шар на доменний шар, а потім раптово виявляєте, що такий самий перехід треба перевіряти ще в іншому місці. Краще тримати правила в сервісі або в окремому компоненті правил, а контролер залишити тонким.
Помилка № 3: «розмазати» правила переходів по коду.
Якщо частина правил перевіряється в одному методі, частина — в іншому, а частина — взагалі «за звичкою» в репозиторії, ви отримаєте суперечності. Найнеприємніший баг тут виглядає так: один endpoint забороняє DONE -> TODO, а інший помилково дозволяє. Тому правило має бути єдиним і централізованим: одна таблиця, один switch, одна точка істини.
Помилка № 4: вважати ARCHIVED просто ще одним звичайним статусом.
У нашому проєкті архів — фінальний статус. Переходи з ARCHIVED заборонені, і це важливо не лише для «краси», а й для життєвого циклу завдання. Коли ви забуваєте про це і дозволяєте ARCHIVED -> IN_PROGRESS, клієнти починають сприймати архів як «паузу», а не як фінал. Потім з’являються вимоги «відновити архів», «історія відновлень», «а чому звіти по архіву стрибають» — і ви випадково побудували половину Jira.
Помилка № 5: змінювати статус до перевірки правила.
Іноді код пишуть у стилі: «спочатку setStatus, потім if (неправильно) throw». Це небезпечно навіть у проєкті, що працює лише в пам’яті, тому що ви вже змінили об’єкт у пам’яті. У більш серйозному середовищі збереження ви ще й ризикуєте частково зафіксувати зміни. Правильний порядок нудний, але надійний: спочатку обчисли, чи можна, потім змінюй.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ