JavaRush /Курси /Spring REST & MVC /Проєктування GlobalExcepti...

Проєктування GlobalExceptionHandler

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

1. Роль доменних винятків у обробці помилок

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

Уявіть: клієнт запитує GET /api/v1/tasks/{taskId}, а задачі немає. Це не «помилка сервера» і вже точно не привід віддавати 500 та робити вигляд, що Spring «не впорався». Це нормальна гілка сценарію, для якої ми повинні вміти стабільно віддавати 404 Not Found і зрозумілий ProblemDetail. Аналогічно, якщо клієнт намагається прикріпити файл до архівної задачі або виконати заборонений перехід статусу, це не «некоректний JSON» і не «неправильний параметр запиту», а саме бізнес-конфлікт, який має стати чесним 409 Conflict.

Головний висновок цієї лекції такий: доменний виняток має бути сигналом змісту, а GlobalExceptionHandler — перекладачем цього змісту на мову HTTP-відповідей, однакову для всього API.

2. Межа шарів: домен і web-шар

Щоб не перетворити застосунок на хаос, нам важливо тримати межу: сервісний шар говорить про доменну проблему через виняток, а web-шар вирішує, як це має виглядати в HTTP. Інакше ви отримаєте сервіси, які знають про HttpStatus, ResponseEntity, application/problem+json, а потім дивуєтеся, чому їх неможливо нормально тестувати й перевикористовувати без MVC.

Дуже корисно один раз подумки “прогнати” запит по конвеєру. У нашому курсі ми спрощуємо інфраструктуру, використовуючи репозиторії в пам’яті, але потік помилок усе одно схожий на бойовий сценарій: контролер викликає сервіс, сервіс працює з репозиторієм, у разі проблеми кидає доменний виняток, той піднімається вгору, а @ControllerAdvice перетворює його на ProblemDetail.

flowchart TD
    A[HTTP-запит] --> B["@RestController"]
    B --> C[TaskService]
    C --> D[Репозиторій у пам'яті]
    D --> C
    C -->|викидає DomainException| E["@ControllerAdvice GlobalExceptionHandler"]
    E --> F["ResponseEntity<ProblemDetail>"]
    F --> G[HTTP-відповідь]

Чому ця схема така важлива саме зараз? Тому що вона утримує чистоту шарів. Контролери стають короткими й читабельними, сервіси не “протікають” HTTP-деталями, а весь стиль помилок збирається в одному місці. І так — це саме той випадок, коли “один файл для помилок” несподівано робить проєкт простішим, а не складнішим.

Один спільний @ControllerAdvice уже вміє перетворювати виняток на HTTP-відповідь. Тепер важливо зробити самі винятки змістовними для застосунку: якщо в advice потрапляють просто випадкові RuntimeException з різними рядками, контракт знову попливе. Тому доменним помилкам потрібна своя нормальна модель.

3. Доменні винятки без HTTP

Перед тим як нарощувати GlobalExceptionHandler доменними правилами, потрібно вирішити, якими взагалі будуть доменні винятки. Тут хочеться зробити максимально просту, але дисципліновану модель. Важливо, щоб доменний виняток був повністю “Spring-free”: він не повинен імпортувати HttpStatus, ResponseEntity, ProblemDetail і взагалі нічого з MVC. Його завдання — виразити прикладний зміст проблеми.

Для Task Tracker API нам зручно мати два елементи: ErrorCode — машиночитний код помилки, який потрапить у ProblemDetail, і базовий клас доменного винятку, який цей код несе. Це не обов’язкова вимога “за підручником”, а просто спосіб уникнути тисячі рядків копіпасту й “магічних рядків” у різних місцях.

ErrorCode: словник помилок домену

Почнемо з простого enum. Він живе в доменному шарі (наприклад, com.example.tasktracker.domain.exception), оскільки ці коди — частина прикладного змісту. Web-шар потім використовуватиме їх для формування відповіді.

package com.example.tasktracker.domain.exception;

public enum ErrorCode {
    // Машиночитні коди: їх зручно повертати клієнту в ProblemDetail (поле code)
    TASK_NOT_FOUND,
    COMMENT_NOT_FOUND,
    INVALID_STATUS_TRANSITION,
    FILE_UPLOAD_NOT_ALLOWED
}

Тут уже є порядок: код помилки не береться “з повітря” рядком "task_not_found" в одному місці й "TASK_NOT_FOUND" в іншому. Один enum — одне джерело правди. Якщо ви помилитесь у назві коду, компілятор буде вашим суворим другом, а не «потім подивимося по логах».

Базовий DomainException: зміст + код, без HTTP

Тепер базовий клас. Він зберігає ErrorCode і повідомлення. Повідомлення (message) — це те, що зазвичай стане detail у ProblemDetail. Код — це те, що стане code. Поки ми свідомо не тягнемо сюди HTTP-статус, щоб не ламати шари.

package com.example.tasktracker.domain.exception;

public abstract class DomainException extends RuntimeException {

    // Код помилки домену: він не про HTTP, а про зміст (що саме порушено)
    private final ErrorCode code;

    protected DomainException(ErrorCode code, String message) {
        super(message); // Повідомлення використаємо як detail у ProblemDetail
        this.code = code;
    }

    public ErrorCode getCode() {
        return code;
    }
}

Це дуже маленький клас, але він робить важливу річ: уніфікує контракт доменних помилок. Будь-який доменний виняток тепер “за замовчуванням” має error code.

Конкретний виняток: TaskNotFoundException

Далі — конкретика. Наприклад, відсутність задачі за ідентифікатором. Зверніть увагу: виняток говорить про домен («задачу не знайдено»), а не про транспорт («404 not found»). Це тонка, але принципова різниця.

package com.example.tasktracker.domain.exception;

public class TaskNotFoundException extends DomainException {

    public TaskNotFoundException(String taskId) {
        // Важливо: тут ми фіксуємо доменний зміст (TASK_NOT_FOUND), а не HTTP-статус
        super(ErrorCode.TASK_NOT_FOUND, "Задачу '%s' не знайдено".formatted(taskId));
    }
}

Подібним чином створюються CommentNotFoundException, InvalidStatusTransitionException та інші. Головне правило: виняток має бути читабельним у логах і зрозумілим за змістом, але не повинен перетворюватися на “HTTP-виняток у домені”.

Мапінг доменних помилок: виняток → HTTP-статус → ProblemDetail + code

Коли винятки готові, виникає головне питання: хто вирішує, який статус віддавати? Відповідь — GlobalExceptionHandler. І щоб він не перетворився на “простирадло умов”, корисно заздалегідь виписати мапінг як маленький контракт. Це не бюрократія, а спосіб зробити API передбачуваним.

Нижче наведено приклад мінімальної таблиці відповідностей для доменних сценаріїв. Ми свідомо не додаємо сюди помилки рівня фреймворка (пошкоджений JSON, невідповідність типів, непідтримуваний тип медіа) — для них у цьому ж handler буде інша гілка через ResponseEntityExceptionHandler.

Доменний сценарій Приклад винятку HTTP-статус code
Ресурс не знайдено TaskNotFoundException 404 Not Found TASK_NOT_FOUND
Підресурс не знайдено CommentNotFoundException 404 Not Found COMMENT_NOT_FOUND
Заборонений перехід статусу InvalidStatusTransitionException 409 Conflict INVALID_STATUS_TRANSITION
Заборонена операція (наприклад, завантаження в архівну задачу) FileUploadNotAllowedException 409 Conflict FILE_UPLOAD_NOT_ALLOWED

Тут важливо, що один і той самий зміст має стабільно давати один статус. Якщо іноді TASK_NOT_FOUND перетворюється на 404, іноді на 400, а іноді на 200 із повідомленням "error": "not found", клієнтський код почне страждати. А клієнт, який страждає, — це як тести, що падають “іноді”: наче й не ваші, але дратують щодня.

4. Реалізація GlobalExceptionHandler

Тепер збираємо сам обробник. Ми хочемо, щоб він був схожий на таблицю правил: який виняток — який статус — який код — який заголовок. І щоб при цьому ProblemDetail збирався однаково, без повторення одного й того самого коду в кожному методі.

Каркас @RestControllerAdvice

Сам клас зазвичай зручно тримати в web/api-зоні, наприклад com.example.tasktracker.api.controller.advice. Це частина API-шару, тому Spring-анотації тут доречні.

package com.example.tasktracker.api.controller.advice;

import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice // Один спільний обробник для всього REST web-шару
public class GlobalExceptionHandler {
}

Поки він порожній, але це вже “якір” — єдина точка, де правила помилок будуть жити централізовано.

Допоміжний метод для складання ProblemDetail

Складати ProblemDetail вручну в кожному @ExceptionHandler швидко набридає. Тому робимо маленький допоміжний метод: базовий overload збирає спільну форму відповіді, а доменні гілки просто додають до неї machine-readable code. Так payload не роздвоюється: змінюється джерело помилки, а не формат відповіді.

import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;

private ProblemDetail problem(HttpStatusCode status, String title, String detail) {
    // Базова заготовка ProblemDetail: status + detail
    ProblemDetail body = ProblemDetail.forStatusAndDetail(status, detail);

    // Короткий заголовок для людини
    body.setTitle(title);

    return body;
}

private ProblemDetail problem(
        HttpStatusCode status,
        String title,
        String detail,
        String code
) {
    // Доменні помилки використовують ту саму форму ProblemDetail, але з додатковим code
    ProblemDetail body = problem(status, title, detail);
    body.setProperty("code", code);
    return body;
}

Зверніть увагу на тонкий момент безпеки й якості контракту. У detail ми кладемо зрозуміле, але контрольоване повідомлення. Ми не кладемо туди ex.toString(), тому що це майже гарантовано потягне зайві внутрішні подробиці в публічний контракт. Для доменних винятків ex.getMessage() зазвичай підходить, бо ми самі її сформували й розуміємо, що там написано.

Обробник TaskNotFoundException404

Тепер додамо конкретний мапінг. Для TaskNotFoundException завжди віддаємо 404 і code TASK_NOT_FOUND. title — короткий заголовок для людини, а detail — те, що пояснює ситуацію конкретніше.

import com.example.tasktracker.domain.exception.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
    ProblemDetail body = problem(
            HttpStatus.NOT_FOUND,      // HTTP-статус для сценарію "ресурс не знайдено"
            "Задачу не знайдено",      // Короткий заголовок
            ex.getMessage(),           // detail: контрольоване повідомлення з доменного винятку
            ex.getCode().name()        // машиночитний code (наприклад, TASK_NOT_FOUND)
    );

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}

Так, тут видно, що ex.getCode().name() дає рядок TASK_NOT_FOUND. Це якраз той самий machine-readable код, який зручно клієнту для обробки.

Обробник конфліктів домену → 409

Конфліктні бізнес-помилки — заборонений перехід статусу, заборона на завантаження тощо — це 409 Conflict. Тут теж важливо бути послідовним: 409 — не «щось не так із JSON», а «вхід нормальний, але дія суперечить правилам».

Приклад для зміни статусу:

import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;

@ExceptionHandler(InvalidStatusTransitionException.class)
public ResponseEntity<ProblemDetail> handleInvalidStatusTransition(InvalidStatusTransitionException ex) {
    ProblemDetail body = problem(
            HttpStatus.CONFLICT,       // Конфлікт бізнес-правил, а не помилка формату запиту
            "Недопустима зміна статусу",
            ex.getMessage(),
            ex.getCode().name()
    );

    return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}

Так доменні сценарії не створюють другого формату помилок: вони просто збагачують той самий ProblemDetail полем code.

Загальний обробник для “непередбаченого” домену

Іноді хочеться зробити один @ExceptionHandler(DomainException.class) і мапити статус за ErrorCode. Це допустимо, але я б не робив це першим кроком: для початківця окремі методи на окремі винятки читабельніші. Коли винятків стане багато, ви зможете акуратно зробити рефакторинг, але зараз важливіша читабельність коду, ніж ультрауніверсальність.

5. Контролер і сервіс після обробки

Після того як доменні помилки централізовані, контролер нарешті перестає бути місцем для “збирання помилок”. Він говорить тільки про успішний сценарій: які дані приймає і що повертає в разі успіху. А якщо щось не так — виняток підніметься вгору, і GlobalExceptionHandler зробить правильну відповідь.

Приклад сервісу: він шукає задачу і, якщо не знайшов її, кидає TaskNotFoundException. Сервіс не будує ProblemDetail і не обирає статус-код — він просто чесно повідомляє доменний зміст.

import com.example.tasktracker.domain.exception.TaskNotFoundException;

public TaskDetailsResponse getById(String taskId) {
    // Сервіс говорить мовою домену: "задачу не знайдено", а не "поверни 404"
    Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));

    // Успішний сценарій: відображаємо доменну сутність у DTO
    return taskMapper.toDetails(task);
}

А тепер контролер. Він стає дуже коротким, і це не “втрата контролю”, а якраз ознака здорової архітектури.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
    return taskService.getById(taskId);
}

Якщо задача існує — повернеться 200 OK і DTO. Якщо ні — сервіс кине виняток, і ви отримаєте 404 з application/problem+json і кодом TASK_NOT_FOUND.

Приклад того, що побачить клієнт, — спрощено:

{
  "type": "about:blank",
  "title": "Задачу не знайдено",
  "status": 404,
  "detail": "Задачу 'b3d2...' не знайдено",
  "code": "TASK_NOT_FOUND"
}

Так, type за замовчуванням буде about:blank, а instance може бути порожнім — це нормально для поточного кроку. Найважливіше сьогодні — стабілізувати статуси й code для доменних винятків і перестати розмазувати обробку помилок по контролерах.

6. Резервний обробник на 500

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

Саме тут початківці часто роблять два крайні кроки. Перший — віддавати назовні ex.getMessage() і тим самим розкривати внутрішні подробиці, іноді навіть шматки даних. Другий — повертати одну фразу "Error" і втрачати діагностику. Правильний баланс такий: назовні віддаємо контрольоване повідомлення, а всередину — в логи — пишемо подробиці.

Приклад мінімального обробника:

import org.springframework.http.HttpStatus;

@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
    // Остання лінія оборони: ловимо все неочікуване і повертаємо єдиний контракт помилки
    // Важливо: назовні НЕ віддаємо ex.getMessage(), щоб не витекли внутрішні деталі
    ProblemDetail body = problem(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "Внутрішня помилка",
            "Неочікувана помилка",
            "INTERNAL_ERROR"
    );

    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}

Так, INTERNAL_ERROR тут не з доменного ErrorCode: це технічна позначка в тому ж payload. Принцип усе одно один — неочікувані помилки не повинні випадати в окремий формат і перетворюватися на відповіді “як доведеться”.

7. Типові помилки під час проєктування GlobalExceptionHandler

Помилка №1: доменний шар починає імпортувати HttpStatus і “розуміти HTTP”.
Таке часто трапляється, коли хочеться “спростити” й зробити throw new DomainException(HttpStatus.NOT_FOUND, ...). Ви швидко виграєте кілька рядків, але програєте архітектуру: сервіс стає веб-специфічним, і межа між шарами розмивається. Краще тримати HTTP-мапінг у @ControllerAdvice.

Помилка №2: той самий зміст помилки мапиться в різні статус-коди.
Сьогодні ви вирішили, що TASK_NOT_FOUND — це 404, а завтра в іншому контролері хтось повернув 400, тому що «клієнт же прислав неправильний id». Клієнтському коду байдуже, як ви це пояснюєте: йому потрібна стабільна поведінка. Для одного винятку має бути один стабільний статус.

Помилка №3: назовні витікає ex.toString() або технічне “простирадло” замість контрольованого detail.
ex.toString() часто включає ім’я класу винятку, іноді вкладені деталі, іноді внутрішні значення. Це легко перетворюється на публічне розкриття внутрішностей проєкту. Для доменних винятків зазвичай достатньо ex.getMessage(), бо ви самі її формуєте. Для неочікуваних помилок краще віддавати фіксований detail.

Помилка №4: копіпаста складання ProblemDetail в кожному обробнику.
Спочатку здається, що “ну й гаразд, лише три методи”. Потім стає сім. Потім п’ятнадцять. Потім у вас в одному місці code ставиться через setProperty, в іншому забули, у третьому назвали поле errorCode, і контракт розповзся. Допоміжний метод — це не зайвий шар, а захист від розповзання.

Помилка №5: один спільний @ExceptionHandler(Exception.class) без конкретних доменних обробників.
Це “працює”, але вбиває точність. У результаті TaskNotFoundException летить у 500, клієнт отримує "Internal error" і починає думати, що сервер упав. А сервер насправді просто чесно не знайшов задачу. В API це дуже велика різниця.

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