JavaRush /Курси /Spring REST & MVC /ResponseStatusException

ResponseStatusException: швидкий спосіб і межі

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

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 ви знову отримаєте хаос і різні відповіді. Швидкий спосіб не замінює архітектуру — він лише іноді допомагає скоротити шлях.

1
Опитування
Глобальні помилки, рівень 20, лекція 4
Недоступний
Глобальні помилки
Spring REST та винятки
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ