Когда в приложении больше одного контроллера, обработка исключений без общего центра превращается в повторяющийся шаблон: один и тот же try/catch в каждом методе. @RestControllerAdvice решает это — один класс перехватывает исключения со всего приложения и превращает их в HTTP-ответы.
Соответствие «тип исключения → 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-статус |
|---|---|
NotFoundException | 404 Not Found |
ConflictException | 409 Conflict |
AccessDeniedException | 403 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 для доменного правила, которое ещё не разложили по кодам.
Чего обработчик не видит
Главное ограничение, которое надо знать до того, как поверить в «один центр»: обработчик перехватывает исключения, вылетевшие из контроллера. Всё, что случилось раньше, до него не доходит.
Граница проходит по 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.
Что почитать дальше
- Модель ошибок и Problem Details — как выбрать структуру тела ошибки и когда нужны расширения.
- Типичные ошибки при обработке исключений — антипаттерны, которые прячут проблемы вместо их решения.
- REST-ошибки и Problem Details — HTTP-коды, заголовки и формат ответа для REST API.