Глобальная обработка ошибок: @RestControllerAdvice и ProblemDetail

Вместо try-catch в каждом методе ошибки обрабатываются в одном месте — @RestControllerAdvice. Современный формат ответа об ошибке — ProblemDetail по RFC 7807.
Суть: @RestControllerAdvice ловит исключения со всех контроллеров и превращает их в аккуратные HTTP-ответы. ProblemDetail задаёт стандартную структуру тела ошибки.

Без централизованной обработки код контроллеров обрастает блоками try-catch, а клиенты получают разношёрстные ошибки: где-то строка, где-то стектрейс, где-то пусто. Spring предлагает вынести всю обработку в один класс-советник.

Глобальный обработчик

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleNotFound(UserNotFoundException ex) {
        ProblemDetail pd = ProblemDetail
                .forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Пользователь не найден");
        return pd;
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
        return ProblemDetail
                .forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
    }
}

@RestControllerAdvice — это «зонтик» над всеми контроллерами. Метод с @ExceptionHandler ловит указанный тип исключения, откуда бы оно ни прилетело, и формирует ответ.

ProblemDetail — стандарт ошибок

ProblemDetail появился в Spring 6 и реализует RFC 7807. Он задаёт единый формат JSON для ошибок:

// Ответ ProblemDetail сериализуется в такой JSON:
// {
//   "type": "about:blank",
//   "title": "Пользователь не найден",
//   "status": 404,
//   "detail": "Нет пользователя с id 7"
// }

Клиент всегда знает: будет поле status, title и detail. Это убирает разнобой в ошибках по всему API.

Как работает под капотом

Когда из метода контроллера вылетает исключение, DispatcherServlet ищет подходящий @ExceptionHandler — сперва в самом контроллере, затем в глобальных советниках. Найдя обработчик по типу исключения, он вызывает его и использует результат как ответ.

  Controller бросает UserNotFoundException
        |
        v
  DispatcherServlet ловит исключение
        |
        v
  ищет @ExceptionHandler по типу
   локальный? -> глобальный @RestControllerAdvice
        |
        v
  handleNotFound() -> ProblemDetail (404)
        |
        v
  стандартный JSON клиенту

Смоделируем диспетчер обработчиков ошибок:

# Сопоставление типа ошибки с обработчиком, как в @RestControllerAdvice
class UserNotFound(Exception): pass
class BadInput(Exception): pass

def problem_detail(status, title, detail):
    return {"status": status, "title": title, "detail": detail}

handlers = {
    UserNotFound: lambda e: problem_detail(404, "Не найдено", str(e)),
    BadInput:     lambda e: problem_detail(400, "Неверный ввод", str(e)),
}

def dispatch(exc):
    for exc_type, handler in handlers.items():
        if isinstance(exc, exc_type):
            return handler(exc)
    return problem_detail(500, "Внутренняя ошибка", "Неизвестно")

print(dispatch(UserNotFound("Нет пользователя с id 7")))
print(dispatch(BadInput("Возраст отрицательный")))
print(dispatch(RuntimeError("сбой")))

Нажмите «Попробуй сам ▶» — каждая ошибка маршрутизируется к своему обработчику и превращается в структурированный ответ.

Частые ошибки

  • Глотать исключения. Пустой catch без логирования и ответа прячет проблему от клиента и разработчика.
  • Отдавать стектрейс клиенту. Внутренние детали — в логи, клиенту — аккуратный ProblemDetail.
  • Один обработчик на Exception.class. Слишком общий перехват маскирует разные ошибки одинаковым статусом.

Best practices

  • Заведите свои исключения предметной области (UserNotFoundException) и маппьте их на статусы.
  • Используйте ProblemDetail как единый формат ошибок (RFC 7807).
  • Логируйте 5xx с деталями, клиенту отдавайте безопасное сообщение.

Итог: @RestControllerAdvice централизует обработку ошибок, а ProblemDetail делает их единообразными. Контроллеры остаются чистыми, клиент получает предсказуемые ответы.

Закрепим главное

Централизованная обработка ошибок — это про чистоту и единообразие одновременно. Контроллеры остаются тонкими, потому что не обрастают блоками try-catch, а клиент получает ошибки в одном предсказуемом формате независимо от того, в каком уголке приложения они возникли. Заведите небольшой набор собственных исключений предметной области и сопоставьте каждое со своим статусом — это сделает поведение API очевидным.

Отдельно держите в голове границу между тем, что видит клиент, и тем, что попадает в логи. Стектрейсы, имена классов и SQL — это внутренняя кухня, её место в логах сервера. Клиенту достаётся аккуратный ProblemDetail с понятным сообщением и кодом. Такое разделение одновременно повышает безопасность (вы не раскрываете внутреннее устройство) и удобство (клиент видит человеко-читаемую причину). Для ошибок 5xx обязательно логируйте детали, иначе диагностировать сбой в проде будет нечем.

Проверьте себя
1. Что делает класс, помеченный @RestControllerAdvice?
AУскоряет контроллеры
BЦентрализованно ловит исключения со всех контроллеров и формирует HTTP-ответы
CСоздаёт новые эндпоинты
DЗаменяет сервисный слой
2. Что такое ProblemDetail в Spring 6?
AКласс для логирования
BСтандартная структура тела ошибки по RFC 7807 (status, title, detail)
CАннотация для валидации
DТип базы данных