Данные, которые приходят из запроса, могут быть чем угодно. Bean Validation — стандарт Jakarta EE, который позволяет описать правила прямо в классе DTO и не засорять бизнес-логику проверками if (name == null || name.isBlank()).
Главное в этом стандарте — не список аннотаций, а то, что их запускает: правила лежат в классе всегда, но срабатывают только там, где стоит @Valid.
Аннотации в DTO — только описание. Сколько из четырёх правил реально проверится, решает @Valid: нет на параметре — ноль проверок, нет на вложенном поле — только две верхние. Пустой city в обоих случаях доезжает до метода.
Зачем это нужно
Без валидации каждый контроллер или сервис вынужден сам проверять входные данные. Получается повторяющийся, хрупкий код. Bean Validation решает задачу иначе: правила объявляются один раз — в классе-объекте передачи данных (DTO) — и Spring применяет их автоматически до того, как управление попадёт в метод контроллера.
Аннотации: что ставить на поля DTO
Что ставить, зависит от того, какой мусор приходит в поле: null, пустая строка из пробелов, слишком длинный текст, число вне диапазона, кривой email. Все аннотации из пакета jakarta.validation.constraints.*:
public record CreateUserRequest(
@NotBlank String username,
@Email @NotBlank String email,
@Size(min = 8, max = 72) String password,
@Min(0) @Max(150) int age,
@Pattern(regexp = "\\+\\d{7,15}") String phone
) {}
Самая частая путаница здесь между @NotNull и @NotBlank: первая пропустит строку из пробелов, вторая требует хотя бы один непробельный символ.
@Size ограничивает длину строки или коллекции, @Min и @Max диапазон числа, @Email формат почты, а @Pattern принимает произвольное регулярное выражение для всего, что выше не покрыто.
Каждая аннотация принимает атрибут message для переопределения текста ошибки — подробнее о кастомных сообщениях в статье про собственные аннотации и сообщения.
@Valid в контроллере запускает проверку
Зависимость нужно подключить отдельно — spring-boot-starter-validation. До Spring Boot 2.3 она входила в веб-стартер, и её действительно не добавляли; потом её вынесли, и с тех пор это самая частая причина «поставил @Valid, а проверок нет»: ошибки не будет, аннотации просто перестанут работать.
Одна аннотация @Valid перед параметром — и Spring проверяет всё DTO перед вызовом метода:
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public ResponseEntity<Void> create(@Valid @RequestBody CreateUserRequest request) {
// сюда код попадает только если все проверки прошли
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
Без @Valid аннотации на полях DTO ни на что не влияют — Spring просто не запустит проверку.
Вложенные объекты и каскад
Если DTO содержит другой объект, аннотации на его полях не сработают автоматически. Нужно поставить @Valid на само поле — тогда Spring проверит вложенный объект каскадно:
public record CreateOrderRequest(
@NotNull @Valid AddressRequest address,
@Size(min = 1) List<@Valid OrderItemRequest> items
) {}
public record AddressRequest(
@NotBlank String city,
@NotBlank String street
) {}
Без @Valid на поле address проверка остановится на уровне CreateOrderRequest и не зайдёт внутрь AddressRequest.
Группы валидации
Иногда одно поле нужно проверять по-разному в зависимости от операции. Для этого Bean Validation поддерживает группы:
public interface OnCreate {}
public interface OnUpdate {}
public record UserRequest(
@NotNull(groups = OnCreate.class) String username,
@NotBlank(groups = {OnCreate.class, OnUpdate.class}) String email
) {}
В контроллере вместо @Valid используется @Validated с указанием группы:
@PostMapping
public ResponseEntity<Void> create(@Validated(OnCreate.class) @RequestBody UserRequest request) { ... }
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody UserRequest request) { ... }
Тут прячется ловушка. Как только у ограничения появилась своя группа, из обычной проверки оно выпадает: @Valid (и @Validated без аргументов) запускает только группу по умолчанию, а @NotNull(groups = OnCreate.class) в неё уже не входит. Поставьте на третьем эндпоинте простой @Valid — и поле username молча проедет без всякой проверки.
На практике группы нужны редко — чаще достаточно отдельных DTO для создания и обновления.
Ограничения не только на теле запроса
@Valid перед объектом тела — самый известный случай, но не единственный, и остальные ведут себя иначе.
Параметры запроса и пути. Ограничения ставят прямо на аргументах, но работать они будут только если на классе есть пометка проверяемого:
@RestController
@Validated // без этого ограничения ниже молчат
public class OrderController {
@GetMapping("/orders")
public List<OrderRow> list(@RequestParam @Min(1) @Max(200) int size,
@RequestParam @Pattern(regexp = "PAID|SHIPPED") String status) { … }
}
Механизм здесь другой: проверку делает обёртка вокруг бина, а не разбор тела. Отсюда и другое исключение — ConstraintViolationException, и ему нужен свой обработчик: без него в старых версиях Spring клиент получал 500, а в новых — 500 или невнятный 400 без списка полей. Плюс путь к полю в таком нарушении выглядит иначе: он включает имя метода (list.size), и его приходится подрезать, чтобы отдать клиенту понятное имя параметра.
Методы сервисов. Та же пометка работает и на обычных бинах — ограничения на аргументах и на возвращаемом значении проверяются при каждом вызове. Пользоваться этим стоит осторожно: проверка на каждом вызове внутреннего метода — это накладные расходы и неожиданные исключения в середине сценария. Обычно ограничения оставляют на границе, а внутри домена правила проверяет сам домен.
Почему аннотация внутри угловых скобок
Строка List<@Valid OrderItemRequest> items выглядит опечаткой, а разница принципиальна.
@Valid List<OrderItemRequest> проверяет сам список — и для списка это почти ничего не значит (у него нет ограничений). Элементы при этом не проверяются.
List<@Valid OrderItemRequest> проверяет каждый элемент: аннотация стоит на типе элемента, и валидатор обходит коллекцию. Именно это обычно и нужно.
Так же работают остальные ограничения на элементах: List<@NotBlank String> tags проверит каждую строку, Map<String, @Positive Integer> quantities — каждое значение. А @NotEmpty на самом поле проверяет, что список непустой, — это про список, и ставят его рядом:
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty List<@Valid OrderLineRequest> lines) {}
Проверяются все ограничения, а не первое
Важное свойство, на котором стоит вся форма ответа с ошибками: валидатор не останавливается на первом нарушении. Он собирает все и отдаёт списком — поэтому клиент может подсветить сразу четыре неправильных поля, а пользователь исправляет их за одно нажатие вместо четырёх кругов.
Именно поэтому в ответе об ошибке массив нарушений, а не одно сообщение, — как описано в статье про формат ошибок. И именно поэтому не стоит писать свои проверки в стиле «первая ошибка — выход»: клиент получит их по одной.
Оговорка: порядок нарушений в списке не гарантирован. Если важен предсказуемый порядок (например, для тестов), список сортируют по имени поля перед отдачей.
Аннотации, о которых забывают
Базовый набор известен, а эти четыре закрывают частые случаи и избавляют от своих проверок.
@Positive и @PositiveOrZero (а также @Negative, @NegativeOrZero) — понятнее, чем @Min(1), и работают с любым числовым типом.
@Past, @Future, @PastOrPresent, @FutureOrPresent — для дат: «дата рождения в прошлом», «срок действия в будущем». Берут текущее время из настраиваемого источника, поэтому в тестах его можно подменить.
@DecimalMin и @DecimalMax — для денег. Это важная тонкость: @Min и @Max принимают целое число, и на BigDecimal они сравнивают с ним же, отбрасывая дробную часть ожиданий — то есть @Min(1) пропустит 0.5? Нет, отклонит; но выразить «не меньше 0.01» через @Min нельзя вовсе. Для дробных значений берут @DecimalMin("0.01"), а заодно @Digits(integer = 10, fraction = 2), чтобы ограничить разрядность.
public record PaymentRequest(
@NotNull @DecimalMin("0.01") @Digits(integer = 10, fraction = 2) BigDecimal amount,
@NotNull @FutureOrPresent LocalDate chargeDate) {}
Вместо групп — отдельные типы запросов
Вывод «чаще достаточно отдельных объектов» стоит показать, потому что именно эту альтернативу статья и рекомендует.
// создание: имя обязательно, идентификатора ещё нет
public record CreateCustomerRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Email String email) {}
// обновление: меняют часть полей, идентификатор в пути
public record UpdateCustomerRequest(
@Size(max = 200) String name,
@Email String email) {}
Выигрыш не только в отсутствии групп. У двух операций разные контракты — и это видно в описании API, в сгенерированном клиенте и в тестах; группы же прячут разницу внутрь одного типа, и понять её можно только по коду. Плюс у отдельных типов нет вопроса «а какая группа сработает, если её не указали» (без указания работает группа по умолчанию, и половина ограничений молча не проверяется).
Группы остаются полезными в одном случае: когда объект действительно один, а проверок два набора по этапам одного процесса — черновик и отправка на подпись. Тогда группы честнее, чем два почти одинаковых типа.
Что происходит при ошибке
Если хотя бы одно ограничение нарушено, Spring выбрасывает MethodArgumentNotValidException. По умолчанию это 400 Bad Request с телом, в котором есть время, код, путь — но нет главного: какие именно поля не прошли проверку. Показать их можно настройкой server.error.include-binding-errors, однако в рабочем API ответ об ошибке обычно собирают сами: ловят MethodArgumentNotValidException в общем обработчике и отдают Problem Details со списком полей и причин. Как устроен такой обработчик, разобрано в статье про глобальную обработку ошибок.
Путь одной ошибки сверху вниз: метод контроллера не вызывается вовсе, а поле и текст в ответе появляются только потому, что их собрал обработчик.
Чтобы вернуть понятную ошибку в едином формате, нужен глобальный обработчик — @RestControllerAdvice с методом на MethodArgumentNotValidException. Как его написать, описано в статье Обработка ошибок REST API в Java.
Глубокие правила оформления валидации (какой слой отвечает за что, когда @Validated на сервисе, а когда нет) — в стайл-гайде по валидации.
Глубже: до валидации: сломанный JSON и неверный тип полярасширенное
Половина 400 в проде происходит до того, как Bean Validation вообще запустилась, и выглядит иначе, чем ошибки из этой статьи.
Прежде чем проверить @NotBlank на поле email, Spring обязан превратить тело запроса в объект. Если тело не разбирается, объекта нет, @Valid не срабатывает, и наружу летит HttpMessageNotReadableException. Причины три, и все они дают одно исключение: JSON синтаксически сломан (лишняя запятая, обрезанное тело), тип поля не совпал ("quantity": "два" в int, "2026-13-45" в дату, строка в перечисление, которого нет) и тело пустое при обязательном @RequestBody.
Клиент при этом получает ответ по умолчанию: 400 с ProblemDetail, где в detail написано «Failed to read request», и никакого violations, никакого имени поля. Для фронтенда это тупик.
Лечится отдельным обработчиком в том же @RestControllerAdvice, который достаёт из причины то, что можно показать:
@ExceptionHandler(HttpMessageNotReadableException.class)
ProblemDetail unreadable(HttpMessageNotReadableException e) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
if (e.getCause() instanceof InvalidFormatException ife) {
String field = ife.getPath().stream().map(Reference::getFieldName).collect(joining("."));
pd.setProperty("violations", List.of(Map.of("field", field, "message", "неверный формат значения")));
} else {
pd.setDetail("Тело запроса не разбирается");
}
return pd;
}
InvalidFormatException от Jackson знает путь до поля и ожидаемый тип, и из него собирается та же запись violations, что и у обычной ошибки валидации, поэтому клиент видит один формат. MismatchedInputException покрывает случаи вроде массива вместо объекта, UnrecognizedPropertyException неизвестное поле, если их запрещать, о чём говорит статья про границу. Синтаксическую ошибку JSON в поле не превратить, и ей достаточно detail.
Порядок проверок при этом фиксирован и объясняет, почему клиент видит ошибки «партиями»: сначала разбор тела целиком (одна ошибка, первая встреченная), потом Bean Validation (все нарушения сразу), потом доменные проверки в сервисе. Клиент, исправивший тип поля, получит следующим ответом список нарушений, которых раньше не видел, и это нормально.
Коротко
@NotBlank,@Size,@Email,@Min/@Max,@Pattern— аннотации изjakarta.validation.constraints.*описывают правила прямо в DTO.@Validперед параметром метода запускает проверку; без него аннотации не работают.- Для вложенных объектов нужен
@Validна самом поле — иначе проверка не уйдёт глубже. - При нарушении Spring выбрасывает
MethodArgumentNotValidException— обрабатывается в@RestControllerAdvice. - Группы валидации (
@Validated(Group.class)) нужны редко; в большинстве случаев лучше использовать отдельные DTO. - Сломанный JSON и неверный тип поля это
HttpMessageNotReadableExceptionдо@Valid, безviolations; отдельный обработчик достаёт поле изInvalidFormatExceptionи отдаёт тот же формат ошибки. - Ограничения на
@RequestParamи@PathVariableработают только с пометкой@Validatedна классе и дают другое исключение (ConstraintViolationException), которому нужен свой обработчик. List<@Valid Item>проверяет элементы,@Valid List<Item>— сам список;@NotEmptyставят рядом, для непустоты.- Валидатор собирает все нарушения, а не падает на первом — поэтому в ответе массив, и пользователь правит поля за одно нажатие.
- Для денег берут
@DecimalMinи@Digits, для дат@Past/@Future, вместо групп — отдельные типы запросов на создание и обновление.
Что почитать дальше
- Где проводить валидацию в Spring-приложении — какой слой за что отвечает.
- Собственные аннотации и сообщения об ошибках — как выйти за рамки стандартных ограничений.
- Обработка ошибок REST API в Java — как красиво вернуть
400с деталями нарушений.