Перейти к основному содержимому

Ошибки REST — @Valid и @ControllerAdvice

Разработчику Архитектору

Ошибки REST — @Valid и @ControllerAdvice

Рабочий REST на Spring Boot возвращает предсказуемые ошибки: JSON с кодом и описанием, а не HTML-страницу Whitelabel с трассировкой.

@ControllerAdvice — класс "рядом с контроллерами", который перехватывает исключения по всему приложению и формирует ответ. Один раз настроили — все @RestController получают единый формат.

Связано: Spring Security · JWT.


Зачем это нужно в реальном проекте

Единый формат ошибок в API быстро окупается по трём причинам:

  • фронтенд и мобильные клиенты получают предсказуемый JSON и могут стабильно показывать ошибки;
  • логи и мониторинг проще агрегировать по полям status, title, type;
  • команда быстрее диагностирует инциденты, потому что ответы стандартизированы.

Словарь

ТерминОбъяснение
HTTP 400 Bad RequestКлиент прислал неверные данные (пустое поле, слишком длинный текст)
HTTP 404 Not FoundРесурс с таким id отсутствует
HTTP 401 / 403Нет входа / нет прав (часто от Security до контроллера)
Bean ValidationАннотации @NotBlank, @Size на полях DTO; проверка до бизнес-логики
@Valid"Проверь этот объект перед вызовом метода"
ProblemDetailСтандарт RFC 7807: поля type, title, status, detail в JSON
DTOОбъект запроса/ответа API (CreateNoteRequest), отделён от entity БД
Практическое задание

Отправьте POST /api/notes с пустым text — получите 400 и список полей. Затем исправьте тело и убедитесь, что приходит 201.


Что получится

ЗапросОтвет
POST /api/notes с пустым text400 + список полей
GET /api/notes/999404 + ProblemDetail
Успешный POST201 + тело

Зависимости

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Разбор:

  • spring-boot-starter-validation подключает Bean Validation (Jakarta Validation + Hibernate Validator) в приложение.
  • После добавления зависимости Spring автоматически валидирует DTO, помеченные @Valid, в контроллерах.
  • Без этого starter аннотации @NotBlank, @Size, @Min на входных моделях не дадут ожидаемой автоматической проверки.
  • На практике это ключевой модуль для корректных ответов 400 Bad Request при неверном пользовательском вводе.

(Часто уже транзитивно из spring-boot-starter-web.)


DTO с валидацией

package com.example.notes;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateNoteRequest(
@NotBlank(message = "text обязателен")
@Size(max = 500, message = "не длиннее 500 символов")
String text
) {}

Разбор:

  • record CreateNoteRequest(...) описывает DTO входящего запроса и делает контракт API явным.
  • @NotBlank проверяет, что поле text не null, не пустое и не состоит только из пробелов.
  • @Size(max = 500) ограничивает длину поля и защищает сервис от чрезмерно длинного контента.
  • message = "..." задаёт пользовательский текст ошибки, который попадёт в ответ и упростит работу клиентам API.
  • Комбинация аннотаций переносит базовую валидацию к границе системы до бизнес-логики.

Разбор аннотаций:

АннотацияСмысл
@NotBlankСтрока не null, не пустая, не только пробелы
@Size(max = 500)Длина строки не больше 500
message = "..."Текст попадёт в FieldError и в detail ответа

Record в Spring Boot 3 — обычный DTO: валидация работает на компонентах конструктора.

Что произойдёт без @Valid: пустой text дойдёт до сервиса; клиент получит 500 или мусор в данных вместо понятного 400.


Простой сервис (в памяти)

Код ITЗагрузка примера кода…

Разбор:

  • @Service регистрирует класс как Spring-бин сервисного слоя.
  • ConcurrentHashMap обеспечивает потокобезопасное хранение данных в памяти для примера без БД.
  • AtomicLong seq генерирует уникальные id без гонок в многопоточном доступе.
  • Метод create создаёт запись и возвращает DTO Note с новым идентификатором.
  • find возвращает Optional<Note>, поэтому контроллер явно решает, что делать при отсутствии записи.

Контроллер

Код ITЗагрузка примера кода…

Разбор:

  • @RestController включает обработку HTTP-запросов и автоматическую JSON-сериализацию ответов.
  • @RequestMapping("/api/notes") задаёт общий префикс маршрутов для заметок.
  • @PostMapping с @Valid @RequestBody парсит JSON в DTO и запускает валидацию до входа в метод.
  • ResponseEntity.status(HttpStatus.CREATED) возвращает корректный HTTP 201 при успешном создании.
  • orElseThrow(() -> new NoteNotFoundException(id)) превращает отсутствие записи в доменное исключение для централизованной обработки.

Разбор контроллера:

ЭлементРоль
@PostMappingHTTP POST на /api/notes
@Valid @RequestBody CreateNoteRequestРазбор JSON + валидация; при ошибке метод не вызывается
ResponseEntity.status(CREATED)Статус 201 и тело созданной заметки
orElseThrow(() -> new NoteNotFoundException(id))Нет id → исключение → advice вернёт 404

Доменное исключение

public class NoteNotFoundException extends RuntimeException {
public NoteNotFoundException(long id) {
super("Note not found: " + id);
}
}

Разбор:

  • Класс наследуется от RuntimeException, поэтому его можно пробрасывать без объявления в throws.
  • Конструктор принимает id и формирует осмысленное сообщение для API-ответа и логов.
  • Такое исключение выражает доменную ситуацию "ресурс не найден" и отделяет её от технических ошибок.
  • Через @ControllerAdvice оно стабильно маппится в 404 Not Found вместо случайного 500.

@ControllerAdvice

Код ITЗагрузка примера кода…

Разбор:

  • @RestControllerAdvice создаёт глобальный слой обработки исключений для всех REST-контроллеров приложения.
  • @ExceptionHandler(MethodArgumentNotValidException.class) собирает ошибки валидации и формирует ответ 400.
  • Collectors.joining("; ") объединяет ошибки полей в одно читаемое поле detail.
  • ProblemDetail.forStatusAndDetail(...) создаёт RFC 7807-совместимое тело ошибки с HTTP-статусом и пояснением.
  • Отдельный обработчик NoteNotFoundException стандартизирует ответ 404, чтобы клиенты получали единый формат ошибок.

Разбор @RestControllerAdvice:

МетодИсключениеОтвет
handleValidationMethodArgumentNotValidException400, в detail — список полей
handleNotFoundNoteNotFoundException404, сообщение из исключения

ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, details) — фабрика Spring 6; сериализуется в JSON:

{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "text: text обязателен"
}

Разбор:

  • type хранит URI типа проблемы; about:blank означает базовую, некастомную категорию.
  • title даёт короткое человекочитаемое название ошибки для UI и логов.
  • status дублирует HTTP-код внутри JSON, что удобно клиентам при унифицированной обработке.
  • detail содержит конкретное объяснение проблемы для пользователя или разработчика клиента.
  • Такой формат легко расширяется дополнительными полями (traceId, errors) без ломки базового контракта.

Фронтенд может показать detail пользователю или разобрать поля из строки; для сложных форм позже добавляют массив errors[] в кастомном теле.

Порядок обработки запроса:

POST /api/notes + JSON
→ SecurityFilterChain (если включён)
→ DispatcherServlet → HandlerMapping → NoteController.create
→ preHandle() у HandlerInterceptor (если зарегистрированы)
→ валидатор (@Valid) → 400 через @ControllerAdvice
→ сервис / репозиторий
→ postHandle() → сериализация JSON → 201 + тело
→ afterCompletion()

Разбор:

  • Первая строка фиксирует вход: HTTP POST на endpoint заметок с JSON-телом.
  • SecurityFilterChain — сервлет-фильтры до Spring MVC; при 401/403 контроллер не вызывается.
  • DispatcherServlet и HandlerMapping выбирают handler-метод; подробная схема — жизненный цикл запроса.
  • preHandle() / postHandle() / afterCompletion() — этапы HandlerInterceptor вокруг вызова контроллера.
  • На этапе @Valid возможен ранний выход с 400, если DTO не проходит Bean Validation.
  • При корректных данных выполняется NoteController.create, и клиент получает 201 Created с телом ресурса.

Проверка

curl -s -X POST http://localhost:8080/api/notes \
-H "Content-Type: application/json" \
-d '{"text":""}' | jq .

curl -s http://localhost:8080/api/notes/99999 | jq .

Разбор:

  • Первый curl отправляет POST с пустым text, чтобы проверить, что валидация реально возвращает 400.
  • Заголовок Content-Type: application/json обязателен для корректного маппинга тела запроса в DTO.
  • jq . форматирует JSON-ответ и делает визуальную проверку структуры ошибки быстрее.
  • Второй curl проверяет путь 404 Not Found для отсутствующей заметки.
  • Два запроса вместе покрывают основные сценарии негативного тестирования API.

Ожидайте status: 400 и 404 без HTML Whitelabel.


Security + ошибки

При JWT неавторизованный запрос даёт 401 от Security до контроллера. Чтобы формат совпадал, можно добавить AuthenticationEntryPoint, возвращающий ProblemDetail — опционально для углубления.


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

СимптомПричина
500 вместо 400Нет @Valid или нет starter-validation
Advice не срабатываетКласс вне scan-пакета @SpringBootApplication
Пустой detailНет message в аннотациях @NotBlank

Что попробовать

  1. @Email, @Min на других полях.
  2. @ControllerAdvice + ResponseEntity с кастомным телом { code, errors[] } для legacy API.
  3. Интеграционный тест MockMvcstatus().isBadRequest() и jsonPath("$.detail").

Дальше

JWT · JPA · JUnit / MockMvc


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.


Минимальный контракт ошибки для команды

Даже если API пока маленькое, зафиксируйте контракт ответа:

{
"type": "https://example.org/problems/validation-error",
"title": "Validation failed",
"status": 400,
"detail": "text: text обязателен",
"traceId": "6e4f9b4f2f9c0f3a"
}

Разбор:

  • type с постоянным URI задаёт машинно-стабильный идентификатор категории ошибки.
  • traceId связывает ответ пользователю с конкретной записью в логах/трейсинге.
  • Клиенты могут ветвить обработку по type, не завязываясь на локализованный текст detail.
  • Поле status позволяет унифицировать обработку ошибок даже вне HTTP-контекста (например, в SDK).
  • Такой контракт удобно документировать в OpenAPI и использовать как базу для командной договорённости.

traceId особенно полезен в проде: пользователь присылает ID, и команда быстро находит нужный запрос в логах.


Валидация вложенных DTO

Для реальных форм почти всегда нужно проверять вложенные объекты и списки:

public record CreateOrderRequest(
@NotBlank String customerId,
@Valid List<CreateOrderItemRequest> items
) {}

public record CreateOrderItemRequest(
@NotBlank String sku,
@Min(1) int quantity
) {}

Разбор:

  • CreateOrderRequest моделирует верхний уровень входного payload для создания заказа.
  • @Valid на List<CreateOrderItemRequest> включает каскадную проверку каждого элемента списка.
  • Во вложенном DTO @NotBlank String sku гарантирует наличие артикула в каждой позиции.
  • @Min(1) int quantity запрещает нулевое и отрицательное количество товара.
  • Такой подход поднимает ошибки структуры заказа на уровень API и избавляет бизнес-слой от повторяющихся проверок.

@Valid на коллекции нужен обязательно, иначе вложенные элементы не пройдут Bean Validation и ошибка всплывёт позже в бизнес-логике.


Версионирование формата ошибок

Когда API растёт, добавьте отдельный type для каждого класса ошибок:

  • .../validation-error
  • .../not-found
  • .../conflict
  • .../access-denied

Это позволяет клиентам обрабатывать ошибки по стабильному машинному признаку, а не парсить текст detail.


Практика для собеседования и проекта

  1. Добавьте обработку MethodArgumentTypeMismatchException и верните аккуратный 400.
  2. Покройте @ControllerAdvice интеграционными тестами через MockMvc.
  3. Добавьте глобальный traceId из MDC в каждую ошибку.
  4. Синхронизируйте формат ошибок между Spring Security и контроллерами.

Смежные главы: