1. Роль ResponseStatusException і ризик звикання
ResponseStatusException — це як кнопка «виправити все одним натисканням»… але без гарантій, що ви не натиснете її не там, де треба. Вона існує тому, що іноді справді хочеться дуже швидко сказати Spring: «Ось статус, ось причина, віддай це клієнту». Особливо коли ви створюєте прототип або коли помилка є суто вебовою і не заслуговує окремого доменного винятку.
Проблема в тому, що ResponseStatusException — це не просто виняток. Вона переносить HTTP-рішення — який статус повернути — прямо туди, де ви її кидаєте. І саме тут починаються «веселі» наслідки для архітектури: якщо зловживати цим підходом, сервісний шар починає думати про HTTP, контролери починають змішувати різні стилі помилок, а клієнт API раптово отримує то один формат ProblemDetail, то інший. У підсумку маємо API, у якому поведінка передбачувана, але не всюди однакова.
Щоб розмова була чесною, давайте спочатку розберемо, що саме робить ResponseStatusException, а потім сформулюємо зрозумілі правила: де їй можна жити, а де краще її не підживлювати, інакше вона розростеться.
Будова ResponseStatusException
Якщо доменний виняток на кшталт TaskNotFoundException відповідає на запитання «що сталося в предметній області», то ResponseStatusException відповідає на запитання «як саме це віддати клієнту через HTTP». Усередині неї є status (HTTP-код) і «reason» (зрозуміла для людини причина), а ще за потреби — cause (першопричина, якщо ви загортаєте інший виняток).
На рівні «що написати в коді» все виглядає дуже привабливо: один рядок — і ви вже отримали 404.
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
// Викидаємо виняток, який безпосередньо кодує HTTP-відповідь (статус + detail для клієнта)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Задачу не знайдено");
З погляду Spring MVC це сигнал: «Не треба падати в 500 і робити вигляд, що це неочікувана помилка. Це очікувана ситуація, просто поверни її клієнту як коректну HTTP-відповідь».
Є важлива дрібниця: текст, який ви передаєте другим аргументом, часто сприймають як «повідомлення про помилку». Але у світі ProblemDetail (який ми використовуємо як базовий формат) це зазвичай потрапляє в поле detail або стає його частиною. Тобто ви фактично прямо впливаєте на публічний контракт. Це не погано, просто вимагає дисципліни: не можна перетворювати reason на внутрішню нотатку для розробника.
2. Як Spring формує HTTP-відповідь із винятку
Всередині Spring MVC є кілька рівнів обробки винятків. Не потрібно вчити їх як заклинання з «Гаррі Поттера», але корисно розуміти загальну логіку: спершу Spring намагається знайти ваш @ExceptionHandler — у контролері або в @ControllerAdvice. Лише якщо не знаходить, він вмикає стандартні резолвери, серед яких є й обробник ResponseStatusException.
Якщо spring.mvc.problemdetails.enabled=true, Spring уміє віддавати помилки у форматі application/problem+json. У разі ResponseStatusException це зазвичай призводить до того, що клієнт отримує JSON у форматі, подібному до ProblemDetail, зі status і detail. Приклад — форма може трохи відрізнятися, але ідея така:
{
"type": "about:blank",
"title": "Не знайдено",
"status": 404,
"detail": "Задачу не знайдено",
"instance": "/api/v1/tasks/5a4c..."
}
Щоб не заплутатися, корисно тримати в голові спрощену схему потоку:
flowchart TD
A["Метод контролера"] -->|викидає виняток| B["Обробка винятків у Spring MVC"]
B --> C{"Чи є @ExceptionHandler?"}
C -->|Так| D["Ваш GlobalExceptionHandler -> ResponseEntity<ProblemDetail>"]
C -->|Ні| E{"Це ResponseStatusException?"}
E -->|Так| F["Обробник фреймворку -> статус + ProblemDetail"]
E -->|Ні| G["Обробка за замовчуванням -> 500 або інші стандартні відповіді"]
Ключовий висновок тут такий: ResponseStatusException може «обійти» ваш єдиний обробник, якщо ви не ловите її в @ControllerAdvice. Формально Spring усе одно віддасть коректний статус і може навіть віддати ProblemDetail. Але ваш стиль, ваші заголовки, ваша єдина логіка — уже не гарантовані.
3. Швидкий спосіб у контролері: допустимий компроміс
Коли ми говоримо «контролер має бути тонким», це не означає «контролер не може ухвалювати рішення взагалі». Контролер — це межа HTTP. Він уже знає, що таке 404 і 400. Тому якщо десь і допустимо кинути ResponseStatusException без архітектурної катастрофи, то зазвичай саме в контролері або в іншому компоненті вебшару, який чесно живе в зоні api.
Тобто це не друга базова лінія проєкту, а локально допустимий компроміс там, де HTTP-рішення справді народжується на вебмежі.
Наприклад, уявімо, що TaskService повертає Optional<TaskDetailsResponse> — припустімо, ви так вирішили в навчальних цілях. Тоді контролер може швидко перетворити «не знайдено» на 404.
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException;
@GetMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse getById(@PathVariable String taskId) {
// Контролер — вебмежа, тому тут допустимо ухвалити HTTP-рішення (404)
return taskService.findDetails(taskId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Задачу не знайдено"));
}
Це виглядає акуратно, і головне — HTTP-рішення залишилося у вебшарі. Але є дві тонкощі, через які я все одно не робив би це основним стилем проєкту.
Перша тонкість: якщо ви вже обрали основний шлях «доменні винятки + глобальний переклад у ProblemDetail», то додавання другого стилю — «іноді ResponseStatusException прямо тут» — поступово перетворює проєкт на музей підходів. Друга тонкість: тексти «Задачу не знайдено» будуть розмножуватися в коді, і одного дня у вас з’являться «Задачу не знайдено», «Задачу не було знайдено», «Немає такої задачі», «Задача не знайдена» — і клієнт почне думати, що це різні помилки, а це лише різні розробники в різному настрої.
Якщо такий код починає розмножуватися в контролерах, це вже сигнал повертатися до доменних винятків і одного глобального обробника, а не радіти скороченню шляху до одного рядка.
4. ResponseStatusException у сервісі: погана ідея
Сервісний шар у нашому Task Tracker API — це місце, де живе прикладна логіка, але не HTTP. Навіть якщо зараз проєкт без БД і без механізмів безпеки, ми все одно тримаємо архітектуру так, щоб вона потім спокійно пережила підключення реального сховища даних та інші розширення. І саме тому сервіс не повинен знати, що таке 404 і ResponseEntity.
Коли ви кидаєте ResponseStatusException із сервісу, ви непомітно прибиваєте сервіс до Spring Web. Це як поставити на доменну модель наліпку «НЕ ВИКОРИСТОВУВАТИ БЕЗ HTTP». З погляду тестів це теж неприємно: замість перевірки доменної поведінки ви починаєте перевіряти HTTP-семантику в модульних тестах сервісу.
Ось приклад того, як робити не варто — він короткий, але руйнівний:
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public TaskDetailsResponse getDetails(String taskId) {
// Погано: сервіс починає знати про HTTP-статуси і стає залежним від вебшару
return repository.findDetails(taskId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Задачу не знайдено"));
}
А ось приклад «правильного нудного» варіанта — доменний виняток без HTTP:
import com.example.tasktracker.domain.exception.TaskNotFoundException;
public TaskDetailsResponse getDetails(String taskId) {
// Добре: сервіс говорить мовою домену, а не мовою HTTP
return repository.findDetails(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
Саме тут — ключовий архітектурний сенс: сервіс повідомляє про проблему мовою домену («задачу не знайдено»), а вебшар уже вирішує, що це означає 404 Not Found і як саме це виглядає в ProblemDetail.
5. Ризики змішування підходів
Змішування підходів майже ніколи не ламає проєкт одразу. Воно ламає його повільно і, що особливо прикро, не на етапі компіляції, а в контракті. Клієнту API байдуже, як ви там усередині назвали виняток — він бачить лише HTTP-статус, заголовки та JSON-помилку. І якщо одна й та сама ситуація («task not found») оформлена різними шляхами, ви отримаєте різні відповіді.
Щоб побачити проблему, зручно порівнювати не код, а симптоми. Уявіть, що в одній кінцевій точці «не знайдено» летить як TaskNotFoundException, а в іншій — як ResponseStatusException(HttpStatus.NOT_FOUND, "..."). В одному випадку ваш GlobalExceptionHandler збиратиме ProblemDetail із вашим title і вашим detail, в іншому — Spring збиратиме «типовий» ProblemDetail. Обидва варіанти дадуть 404, але зовнішній контракт буде трохи різним.
Порівняти підходи можна в невеликій таблиці:
| Підхід | Де вирішується HTTP-статус | Де живе текст помилки | Ризик «розʼїзду» формату |
|---|---|---|---|
| Доменний виняток + @ControllerAdvice | В одному місці — у глобальному обробнику | В обробнику, контрольовано | Низький, якщо дотримуватися дисципліни |
| ResponseStatusException (кидаємо «де завгодно») | У місці викидання | У місці викидання | Високий: тексти й стиль розповзаються |
| Змішування двох підходів | То тут, то там | То тут, то там | Максимальний: клієнт отримує «два світи» |
Саме тому короткий шлях небезпечний як основний стиль. Він не поганий технічно — він поганий методично й архітектурно: погіршує передбачуваність API, а передбачуваність — це буквально те, заради чого ми будуємо єдиний шар обробки помилок.
6. Безпечний швидкий спосіб через @ControllerAdvice
Іноді ResponseStatusException усе ж корисна як інструмент. Наприклад, ви хочете швидко перервати запит на рівні вебу, і вам не потрібен окремий доменний тип винятку. У такому разі найбезпечніше рішення — усе одно пропустити її через ваш GlobalExceptionHandler, щоб формат відповіді залишався єдиним.
Тобто ми робимо так: ResponseStatusException залишається допустимим швидким способом, але лише за умови, що в нас є централізоване місце, яке перетворює її на «наш» ProblemDetail, а не на «як вийде». Це просто ще один метод у тому ж GlobalExceptionHandler, а не окремий спецобробник поруч.
Приклад обробника — зверніть увагу: ми беремо статус із винятку, а текст reason використовуємо як detail:
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException;
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ProblemDetail> handleResponseStatus(ResponseStatusException ex) {
// Беремо HTTP-статус прямо з ResponseStatusException
HttpStatusCode status = ex.getStatusCode();
// reason — це те, що зазвичай іде назовні в ProblemDetail.detail, тому з ним обережно
String detail = ex.getReason() != null ? ex.getReason() : "Запит не виконано";
ProblemDetail body = problem(status, "Запит не виконано", detail);
return ResponseEntity.status(status).body(body);
}
Той самий допоміжний метод усередині GlobalExceptionHandler залишається без змін:
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
private ProblemDetail problem(HttpStatusCode status, String title, String detail) {
// Збираємо ProblemDetail однаково, щоб контракт не розповзався по проєкту
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
pd.setTitle(title);
return pd;
}
Якщо в проєкті ви вже додаєте code для помилок, цей handler має дотримуватися тієї самої домовленості, а не випускати другий формат відповіді без причини.
Зверніть увагу на важливий ефект: навіть якщо десь за звичкою хтось кине ResponseStatusException, відповідь усе одно пройде через ваш стиль. Тобто ви ніби говорите проєкту: «Гаразд, швидкий спосіб допустимий, але фасад у нас один».
І все ж, навіть із таким хендлером, краще домовитися в команді — і в голові: основний шлях — доменні винятки + глобальний переклад. ResponseStatusException — рідкісний інструмент, як аптечка: добре, що вона є, але жити в аптечці не треба.
@ResponseStatus і ResponseStatusException: спільна пастка
У Spring є ще один популярний швидкий спосіб: анотація @ResponseStatus на класі винятку або іноді на методі контролера. Це теж спосіб «вшити» статус поруч із винятком. Різниця в тому, що @ResponseStatus — це статичне правило («цей виняток завжди означає 404»), а ResponseStatusException — динамічне («зараз хочу 404, а в іншому місці — 409»).
Ця спільна пастка важлива не лише через статуси. Щойно місце обробки помилок стабілізоване, уся робота зміщується у форму самого error response: клієнт має бачити один і той самий shape, незалежно від того, вибрали ви анотацію чи виняток.
Чому ми говоримо про це обережно і без глибокого занурення? Тому що і @ResponseStatus, і ResponseStatusException розв’язують одне й те саме завдання: швидко вказати статус. І в обох є одна й та сама архітектурна небезпека: якщо ви використовуєте їх у доменному або сервісному шарі, ви протягуєте HTTP туди, де йому не місце.
У межах нашого курсу достатньо запам’ятати простий критерій. Якщо помилка — це частина предметної моделі Task Tracker API (наприклад, «задачу не знайдено», «перехід статусу заборонено»), їй пасує власний доменний виняток. Якщо помилка — суто вебісторія (наприклад, ви штучно в контролері перевірили якийсь заголовок і хочете швидко повернути 400), можна розглянути швидкий спосіб, але краще так, щоб загальний формат відповіді не розповзався.
7. Типові помилки під час використання ResponseStatusException
Помилка №1: перетворювати ResponseStatusException на універсальний стиль проєкту.
Спочатку це виглядає красиво: менше класів винятків, менше «зайвої архітектури». А через місяць ви розумієте, що правила вибору статусів і текстів роз’їхалися по коду, і ніхто не може впевнено сказати, чому одне місце віддає 404 з одним detail, а інше — 404 з іншим. У підсумку ви втрачаєте головне — єдиний контракт.
Помилка №2: кидати ResponseStatusException із сервісного шару.
Це майже гарантований архітектурний борг: сервіс починає залежати від Spring Web і HTTP-семантики. Потім ви хочете протестувати сервіс без MVC, перевикористати його, замінити вебшар, а в доменній логіці раптом стирчить HttpStatus. Виходить «сервіс, який думає як контролер», а це не комплімент.
Помилка №3: змішувати в одному проєкті і доменні винятки, і ResponseStatusException для однієї й тієї самої ситуації.
Це найпідступніший випадок, тому що він виглядає як «ну просто так зручніше в цьому методі». Але саме так і з’являється непередбачуваність: клієнт починає отримувати різні відповіді на одну й ту саму подію. Навіть якщо статус однаковий, JSON-форма і тексти можуть відрізнятися, а це вже проблема контракту.
Помилка №4: писати reason так, ніби це внутрішня нотатка для розробника.
Оскільки reason часто потрапляє в detail, він стає публічним. Текст на кшталт «NPE at TaskMapper line 42» або «repo returned null wtf» може бути смішним у логах, але в публічному API він виглядає як витік внутрішньої інформації та як непрофесійність. Навіть якщо це навчальний проєкт, звичка потім переїжджає в реальний.
Помилка №5: вважати, що ResponseStatusException скасовує потребу в глобальному обробнику.
Навіть якщо ви вирішите — що небажано — скрізь кидати ResponseStatusException, у вас усе одно залишаться помилки фреймворку (HttpMessageNotReadableException, MethodArgumentNotValidException та інші), а також неочікувані Exception. Без @ControllerAdvice ви знову отримаєте хаос і різні відповіді. Швидкий спосіб не замінює архітектуру — він лише іноді допомагає скоротити шлях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ