← назад к разделу

Когда в приложении больше одного контроллера, обработка исключений без общего центра превращается в повторяющийся шаблон: один и тот же try/catch в каждом методе. @RestControllerAdvice решает это — один класс перехватывает исключения со всего приложения и превращает их в HTTP-ответы.

GET /orders/{id} — контроллер описывает только успешный путь OrderController: return ok(...)ни одного try/catch в методе throw new NotFoundExceptionдомен про HTTP ничего не знает в методе нет catch — Spring отдаёт исключение в advice@RestControllerAdvice — один класс на всё приложение Validation→ 400AccessDenied→ 403NotFound→ 404Conflict→ 409BusinessRule→ 422 HTTP 404 · application/problem+jsonlog.debug — ожидаемая ошибка, стектрейса нет без общего обработчика тот же разбор — в каждом методе20 методов × 5 типов = 100 веток catch · в advice их 5, в одном классе

Соответствие «тип исключения → HTTP-статус» задано один раз: пять обработчиков вместо ста веток catch, разбросанных по методам, — и уровень лога выбирается там же, где ошибка обрабатывается.

Обязательно

Проблема: try/catch в каждом контроллере

Без глобального обработчика контроллер выглядит так:

@GetMapping("/{id}")
public ResponseEntity<Order> getOrder(@PathVariable UUID id) {
    try {
        return ResponseEntity.ok(orderService.findById(id));
    } catch (OrderNotFoundException e) {
        return ResponseEntity.notFound().build();
    } catch (Exception e) {
        return ResponseEntity.internalServerError().build();
    }
}

Проблемы очевидны: логика повторяется, формат ответа у каждого разработчика свой, тест на обработку ошибки надо писать отдельно для каждого метода.

Короткая формула: контроллер должен описывать успешный путь; что происходит при ошибке — это сквозная ответственность.

@RestControllerAdvice: один центр

Обработчики в каждом контроллере расходятся: один отдаёт 404 с телом, другой пустой 500, и клиент не знает, чего ждать. Spring позволяет вынести обработку исключений в один класс, который перехватывает их из всех контроллеров сразу: это @ControllerAdvice, а @RestControllerAdvice та же аннотация, у которой возвращаемые значения сразу превращаются в тело ответа, как у @RestController:

@RestControllerAdvice
public class GlobalExceptionHandler {
    // методы @ExceptionHandler
}

Spring MVC перехватывает исключение, брошенное из контроллера (или слоя под ним), и передаёт его в подходящий @ExceptionHandler внутри этого класса.

@ExceptionHandler: маппинг исключения на HTTP

Каждый метод @ExceptionHandler отвечает за один или несколько типов исключений:

@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setProperty("code", "ORDER_NOT_FOUND");
    return problem;
}

@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
    problem.setProperty("code", "INVALID_ARGUMENT");
    return problem;
}

ProblemDetail — стандартный формат ответа об ошибке (RFC 9457), встроенный в Spring Boot 3. Подробнее о структуре ответа — в статье REST-ошибки и Problem Details.

Статус ответа Spring берёт из самого ProblemDetail, поэтому дублировать его аннотацией @ResponseStatus не нужно: если значения разойдутся, победит то, что лежит в ProblemDetail. А code кладут рядом с текстом — по нему клиент ветвит логику, не разбирая сообщение по словам.

Маппинг доменных исключений на HTTP-коды

Типовая схема: доменный слой бросает семантичное исключение, обработчик переводит его в HTTP-код. Сам контроллер ничего не знает о статусах:

Доменное исключениеHTTP-статус
NotFoundException404 Not Found
ConflictException409 Conflict
AccessDeniedException403 Forbidden
ValidationException (неверный формат полей)400 Bad Request
BusinessRuleException (поля верны, правило нарушено)422 Unprocessable Entity

Код 422 в свежей редакции HTTP переименовали в «Unprocessable Content» — встретите оба названия, за ними один и тот же статус.

Базовый иерархический подход — одна иерархия исключений в модуле домена, один метод @ExceptionHandler на базовый тип:

@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
    // соответствие «тип ошибки → код HTTP» живёт здесь, в веб-слое,
    // а доменное исключение про HTTP ничего не знает
    HttpStatus status = switch (ex) {
        case NotFoundException e -> HttpStatus.NOT_FOUND;
        case ConflictException e -> HttpStatus.CONFLICT;
        default -> HttpStatus.UNPROCESSABLE_ENTITY;
    };
    ProblemDetail body = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
    return ResponseEntity.status(status).body(body);
}

Разбор именно по switch, а не поиск в Map<Class<?>, HttpStatus>, — потому что карта сравнивает класс точно. Стоит завести OrderNotFoundException extends NotFoundException, и в карте его уже нет: вместо 404 клиент получит значение по умолчанию. switch по типу такого наследника узнаёт, и ветка по умолчанию остаётся тем, чем и должна быть, — 422 для доменного правила, которое ещё не разложили по кодам.

Чего обработчик не видит

Главное ограничение, которое надо знать до того, как поверить в «один центр»: обработчик перехватывает исключения, вылетевшие из контроллера. Всё, что случилось раньше, до него не доходит.

Цепочка фильтров запрос ещё до MVC бросок здесь идёт в ответ контейнера DispatcherServlet граница обработчика запрос дошёл до метода Контроллер бросает исключение исключение ловит advice @RestControllerAdvice problem+json

Граница проходит по DispatcherServlet: всё, что упало выше него, уходит в стандартный ответ контейнера, и обработчик такого исключения не видит.

Фильтры. Фильтр работает до того, как запрос попал в диспетчер, поэтому его исключение уйдёт в стандартный обработчик ошибок сервера — клиент получит страницу или ответ в чужом формате. Лечится обработкой внутри самого фильтра: поймать, собрать тело ошибки и записать в ответ руками.

Безопасность. Отказы аутентификации и авторизации в Spring Security формируются в фильтрах — то есть мимо обработчика. Строка «AccessDeniedException → 403» в таблице верна только для случая, когда исключение брошено внутри контроллера (например, проверкой прав на методе сервиса, вызванном из контроллера); отказ на входе туда не попадёт.

Чтобы формат был один, настраивают точки входа безопасности:

@Bean
SecurityFilterChain filterChain(HttpSecurity http, ObjectMapper mapper) throws Exception {
    return http
        .exceptionHandling(e -> e
            .authenticationEntryPoint((req, res, ex) -> writeProblem(res, mapper, 401, "UNAUTHENTICATED"))
            .accessDeniedHandler((req, res, ex) -> writeProblem(res, mapper, 403, "ACCESS_DENIED")))
        .build();
}

Без этих двух строк в одном API живут две формы ошибок, и клиенты пишут две ветки разбора.

Ошибки при записи ответа. Если исключение возникло, когда часть ответа уже отправлена, статус изменить нельзя — обработчик тут бессилен. Отсюда правило: в моделях ответа не держат логику, которая может упасть.

Несколько обработчиков сразу

Один обработчик на сервис работает, пока сервис небольшой. Дальше их разделяют — и тогда важно, как выбирается нужный.

Правило выбора. Из всех подходящих методов Spring берёт тот, чей тип исключения ближе по иерархии: метод на OrderNotFoundException победит метод на RuntimeException. Если два обработчика одинаково специфичны и лежат в разных классах, порядок определяется @Order — меньшее значение раньше.

Разделение по области. У обработчика есть три способа ограничить, на какие контроллеры он действует:

@RestControllerAdvice(basePackages = "ru.shop.api.admin")      // только админские контроллеры
@Order(10)
public class AdminExceptionHandler { … }

@RestControllerAdvice(assignableTypes = { PublicOrderController.class })
public class PublicApiExceptionHandler { … }

@RestControllerAdvice                                          // общий, самый последний
@Order(Ordered.LOWEST_PRECEDENCE)
public class FallbackExceptionHandler { … }

Типичная рабочая раскладка: один обработчик доменных исключений (общий), один — на ошибки формата и валидации, и, если есть административное API со своим форматом, свой для него. Общий обработчик с Exception в качестве типа ставят последним по порядку, иначе он перехватит всё и остальные не сработают.

Самая частая причина «мой обработчик не вызвался»: в другом классе есть метод на более общий тип, и он идёт раньше по порядку. Проверяется это на старте — Spring логирует обнаруженные обработчики, если включить отладку для веб-слоя.

Что клиенту, что в журнал

Правило разделения простое, но требует связующего звена. Клиент получает безопасный минимум: код состояния, машинный код, человеческое сообщение и идентификатор трассировки. Журнал получает всё остальное: стектрейс, текст исключения от базы, параметры запроса.

@ExceptionHandler(Exception.class)
ResponseEntity<ProblemDetail> onUnexpected(Exception e) {
    String traceId = currentTraceId();                 // из контекста трассировки
    log.error("Необработанная ошибка, traceId={}", traceId, e);   // стектрейс только в журнал

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problem.setTitle("Внутренняя ошибка");
    problem.setDetail("Попробуйте позже. Если повторяется, сообщите идентификатор запроса.");
    problem.setProperty("code", "INTERNAL_ERROR");
    problem.setProperty("traceId", traceId);
    return ResponseEntity.internalServerError().body(problem);
}

Идентификатор трассировки — то самое звено: клиент видит его в ответе, поддержка находит по нему полный путь запроса в журналах и трассировке. Без него совет «причина в журналах» неисполним: журналов много, и найти нужную запись по времени и пути невозможно.

Чего в ответе быть не должно ни при каких условиях: текст исключения от базы (он раскрывает схему), имена классов и пакетов, пути файлов, значения параметров запроса, фрагменты SQL. Всё это уходит в журнал, а клиенту остаётся общая фраза.

Ожидаемые ошибки: тише в журнале, но считать

Совет «404 пишем уровнем отладки» верен и имеет вторую половину, без которой он вреден: ошибка, исчезнувшая из журнала, должна появиться в метриках.

@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail onNotFound(OrderNotFoundException e) {
    log.debug("Заказ не найден: {}", e.orderId());                       // тихо
    meterRegistry.counter("api.errors", "code", "ORDER_NOT_FOUND").increment();   // но видно
    …
}

Зачем: всплеск «не найдено» означает либо сломанного клиента, либо потерянные данные, и заметить его можно только по графику — в журнале отладки он не виден, а уровня ошибки там нет по договорённости. Тот же счётчик даёт и обратное: если доля 404 внезапно упала до нуля, значит клиент перестал ходить.

Практическая раскладка: доменные ошибки — уровень отладки плюс счётчик по коду; технические — уровень ошибки, стектрейс, счётчик и оповещение. Оповещение ставят не на абсолютное число, а на долю от общего потока — иначе рост трафика поднимает тревогу сам по себе.

Ошибки валидации в том же формате

Обработчик, к которому отправляет статья про проверки, выглядит так — и он обязателен, иначе ошибки формы придут в формате Spring, а не в вашем.

@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onInvalidBody(MethodArgumentNotValidException e) {
    List<Map<String, String>> violations = e.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(),
                              "message", String.valueOf(fe.getDefaultMessage()),
                              "rule", String.valueOf(fe.getCode())))
            .toList();

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("Проверка не пройдена");
    problem.setProperty("code", "VALIDATION_FAILED");
    problem.setProperty("violations", violations);
    return problem;
}

К нему нужны ещё два обработчика той же природы: ConstraintViolationException (ограничения на параметрах запроса — там путь к полю содержит имя метода, его подрезают) и HttpMessageNotReadableException (сломанное тело — список нарушений отдать нечего, только общее сообщение). Три обработчика на одну задачу — не избыточность, а следствие того, что проверки срабатывают в трёх разных местах; разбор — в статье про уровни проверок.

Тест обработчика

Обещание «один обработчик проще тестировать» стоит закрыть примером: тест поднимает только веб-слой, контроллер подменён, проверяется именно форма ответа.

@WebMvcTest(controllers = OrderController.class)
@Import(GlobalExceptionHandler.class)
class GlobalExceptionHandlerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;

    @Test
    void доменнаяОшибкаОтдаётся422СКодом() throws Exception {
        given(orders.cancel(any())).willThrow(new OrderCannotBeCancelledException("ord-42", "SHIPPED"));

        mvc.perform(post("/api/v1/orders/ord-42/cancel"))
           .andExpect(status().isUnprocessableEntity())
           .andExpect(content().contentType("application/problem+json"))
           .andExpect(jsonPath("$.code").value("ORDER_CANNOT_BE_CANCELLED"))
           .andExpect(jsonPath("$.detail").exists())
           .andExpect(jsonPath("$.traceId").exists());
    }

    @Test
    void ошибкаВалидацииОтдаётВсеПоля() throws Exception {
        mvc.perform(post("/api/v1/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.violations.length()").value(2))
           .andExpect(jsonPath("$.violations[*].field", containsInAnyOrder("customerId", "lines")));
    }
}

Что важно в этом тесте. @Import обработчика обязателен: тест веб-слоя поднимает контроллер, но не подтягивает обработчик из другого пакета автоматически. Проверяется тип содержимого (application/problem+json) — его легко потерять при ручной сборке ответа. И проверяется наличие идентификатора трассировки: без него ответ формально правильный, а поддержке бесполезный.

Такие тесты пишут по одному на каждый класс ошибок, а не на каждое исключение: задача теста — зафиксировать форму ответа, а не перечислить все правила.

Что логировать в обработчике

Правило: логировать там, где исключение обрабатывается, а не там, где оно бросается. Уровень зависит от серьёзности:

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
    log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), ex);
    return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
}

@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
    log.debug("Order not found: {}", ex.getMessage());
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
  • ERROR / WARN — неожиданные исключения, сбои инфраструктуры.
  • DEBUG — ожидаемые доменные ошибки (не найдено, конфликт): их можно включить при отладке, но не засорять ими production-логи.
  • Стектрейс — только на уровне ERROR; в INFO/DEBUG он не нужен.

У обработчика на Exception.class есть побочный эффект, о котором узнают поздно: он ловит и собственные исключения Spring MVC — те, что летят при неразобранном JSON, непройденной проверке @Valid или несуществующем адресе. Если на них нет более точного обработчика, честные 400 и 404 превратятся в 500. Выписывать их руками не нужно: унаследуйте свой advice от ResponseEntityExceptionHandler — он уже разбирает весь этот набор и отдаёт правильные коды, а вы дописываете сверху только доменные обработчики.

Дополнительно: при первом чтении можно пропустить

Глубже: тест обработчика: @WebMvcTest и MockMvcрасширенное

«Один обработчик проще тестировать» из списка выше требует показать, как. Тестируют не метод обработчика напрямую, а путь запроса целиком: контроллер, разбор тела, валидация, @RestControllerAdvice, сериализация ответа. Для этого есть срез @WebMvcTest: поднимается только веб-слой, без базы и сервисов, и MockMvc шлёт запросы без сети.

@WebMvcTest(OrderController.class)
class OrderErrorsTest {
    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;

    @Test
    void domainExceptionBecomesProblemDetail() throws Exception {
        when(orders.cancel(any())).thenThrow(new OrderAlreadyShippedException("42"));

        mvc.perform(post("/api/v1/orders/42/cancel"))
           .andExpect(status().isConflict())
           .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.code").value("ORDER_ALREADY_SHIPPED"))
           .andExpect(jsonPath("$.detail").doesNotExist());
    }

    @Test
    void validationErrorListsFields() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"customerId\":\"\",\"items\":[]}"))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.violations[?(@.field=='customerId')]").exists())
           .andExpect(jsonPath("$.violations[?(@.field=='items')]").exists());
    }
}

Что здесь проверяется по существу. Первый тест: доменное исключение из сервиса превращается в нужный статус и в тело формата ошибок, и в тело не утекло сообщение исключения с внутренностями (проверка doesNotExist на поле, которого быть не должно, важнее проверки того, что должно быть). Второй: валидация отдаёт все нарушения разом и в том формате, который читает фронтенд. Срез @WebMvcTest подхватывает все @ControllerAdvice приложения сам, поэтому тестируется настоящий обработчик, а не его копия; если обработчик вынесен в отдельный модуль, его добавляют через @Import.

Что стоит покрыть таким тестом один раз на приложение, а не на каждый контроллер: сломанный JSON даёт 400 с телом вашего формата; неизвестный путь даёт 404 в том же формате, а не HTML Spring; необработанное исключение даёт 500 без стектрейса в теле и с traceId; 401 и 403 от Spring Security тоже приходят в вашем формате, для этого в срез добавляют настройку Security через @Import. Эти четыре теста ловят регрессии при обновлении Spring, когда умолчания обработки ошибок меняются, а обработчик, написанный под старые, молча перестаёт срабатывать.

Коротко

  • @RestControllerAdvice — единая точка, где исключения превращаются в HTTP-ответы; контроллеры остаются чистыми.
  • @ExceptionHandler внутри него связывает тип исключения с HTTP-статусом и телом ответа.
  • Доменный слой бросает семантичные исключения; GlobalExceptionHandler переводит их в HTTP-коды — слои не смешиваются.
  • ProblemDetail (Spring Boot 3, RFC 9457) — стандартный формат тела ошибки; статус ответа берётся из него, а рядом кладут code.
  • Обработчик на Exception.class ловит и исключения самого Spring MVC — чтобы 400 и 404 не стали 500, наследуйте advice от ResponseEntityExceptionHandler и ставьте общий обработчик последним по @Order. Он не видит исключений из фильтров: отказы Spring Security (401/403) настраивают своими точками входа, иначе в одном API будет две формы ошибок.
  • Логируйте в обработчике: ERROR со стектрейсом для неожиданного, DEBUG для ожидаемого — но ожидаемое обязательно считают счётчиком по коду, иначе всплеск 404 никто не заметит. Клиенту уходит только код, сообщение и traceId; текст исключения от базы, пути и SQL остаются в журнале.
  • В том же формате обязательно обрабатывают ошибки проверок: MethodArgumentNotValidException для тела, ConstraintViolationException для параметров, HttpMessageNotReadableException для сломанного JSON.
  • Обработчик тестируют путём запроса целиком: @WebMvcTest с MockMvc проверяет статус, тип application/problem+json, поля тела и отсутствие лишнего; четыре сквозных теста (сломанный JSON, неизвестный путь, 500, 401/403) ловят регрессии при обновлении Spring.

Что почитать дальше