Ошибки 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 с пустым text | 400 + список полей |
GET /api/notes/999 | 404 + ProblemDetail |
| Успешный POST | 201 + тело |
Зависимости
<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создаёт запись и возвращает DTONoteс новым идентификатором. findвозвращаетOptional<Note>, поэтому контроллер явно решает, что делать при отсутствии записи.
Контроллер
Код ITЗагрузка примера кода…
Разбор:
@RestControllerвключает обработку HTTP-запросов и автоматическую JSON-сериализацию ответов.@RequestMapping("/api/notes")задаёт общий префикс маршрутов для заметок.@PostMappingс@Valid @RequestBodyпарсит JSON в DTO и запускает валидацию до входа в метод.ResponseEntity.status(HttpStatus.CREATED)возвращает корректный HTTP201при успешном создании.orElseThrow(() -> new NoteNotFoundException(id))превращает отсутствие записи в доменное исключение для централизованной обработки.
Разбор контроллера:
| Элемент | Роль |
|---|---|
@PostMapping | HTTP 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:
| Метод | Исключение | Ответ |
|---|---|---|
handleValidation | MethodArgumentNotValidException | 400, в detail — список полей |
handleNotFound | NoteNotFoundException | 404, сообщение из исключения |
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 |
Что попробовать
@Email,@Minна других полях.@ControllerAdvice+ResponseEntityс кастомным телом{ code, errors[] }для legacy API.- Интеграционный тест
MockMvc—status().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.
Практика для собеседования и проекта
- Добавьте обработку
MethodArgumentTypeMismatchExceptionи верните аккуратный 400. - Покройте
@ControllerAdviceинтеграционными тестами черезMockMvc. - Добавьте глобальный
traceIdиз MDC в каждую ошибку. - Синхронизируйте формат ошибок между Spring Security и контроллерами.
Смежные главы: