← назад к разделу

Данные, которые приходят из запроса, могут быть чем угодно. Bean Validation — стандарт Jakarta EE, который позволяет описать правила прямо в классе DTO и не засорять бизнес-логику проверками if (name == null || name.isBlank()).

Главное в этом стандарте — не список аннотаций, а то, что их запускает: правила лежат в классе всегда, но срабатывают только там, где стоит @Valid.

POST /orders — в теле address.city = "" (пустая строка) правил в DTO: 2 снаружи + 2 внутри AddressRequest @Valid нет нигдевалидатор не запускается0 из 4 правилcity="" в методепроверок не было @Valid только на параметрекаскад в AddressRequest не идёт2 из 4 правилcity="" в методеaddress != null — ок @Valid на параметре и на полекаскад заходит в AddressRequest4 из 4 правилметод не вызван400 Bad Request @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 со списком полей и причин. Как устроен такой обработчик, разобрано в статье про глобальную обработку ошибок.

нарушено правило @NotBlank на пустом поле проверка не прошла валидатор собрал все нарушения нарушений может быть много MethodArgumentNotValidException метод не вызван всплыло до границы @RestControllerAdvice один метод на все собрано тело ответа 400 с violations поле, правило, текст

Путь одной ошибки сверху вниз: метод контроллера не вызывается вовсе, а поле и текст в ответе появляются только потому, что их собрал обработчик.

Чтобы вернуть понятную ошибку в едином формате, нужен глобальный обработчик — @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, вместо групп — отдельные типы запросов на создание и обновление.

Что почитать дальше