JavaRush /Курсы /Spring REST & MVC /Validation stack в Spring Boot

Validation stack в Spring Boot

Spring REST & MVC
15 уровень , 1 лекция
Открыта

1. Validation stack: состав и роль

Если вы когда-нибудь видели код, где в одном месте проверяют title, в другом — assigneeName, а в третьем — внезапно ещё и priority, то вы уже сталкивались с “валидацией как импровизацией”. Validation stack нужен, чтобы превратить эту импровизацию в системный механизм: понятные правила, один движок проверки и предсказуемое место, где запрос останавливается.

В контексте Spring Boot под validation stack мы будем понимать набор компонентов, который позволяет сделать так: вы описали ограничения (constraints) рядом с полями DTO, Spring прочитал JSON в DTO, затем автоматически прогнал проверки, и если что-то не так — запрос закончился ещё до входа в сервис. Важно: это не одна аннотация @Valid, а цепочка из стандарта, реализации и Spring-интеграции.

Чтобы не потеряться, полезно держать в голове простую “схему метро”:

Уровень Кто это За что отвечает
Стандарт (API) Jakarta Bean Validation (jakarta.validation.*) Определяет, что такое @NotBlank, @Size, Validator, ConstraintViolation
Реализация (движок) Hibernate Validator Реально исполняет проверки по аннотациям
Интеграция со Spring Spring Boot + Spring MVC Встраивает проверку в обработку @RequestBody и превращает “нарушения” в стандартный негативный сценарий

2. Bean Validation и зависимости

Очень легко попасть в ловушку: увидеть @NotBlank и подумать, что “это Spring придумал”. На самом деле Bean Validation — это общий стандарт Java-экосистемы (в современном мире под брендом Jakarta), а Spring просто умеет с ним дружить. Это хорошая новость: знание переносится между проектами, а правила на DTO не зависят от того, какой именно фреймворк рядом.

Технически ключевая идея Bean Validation простая: вы помечаете поля (или record-компоненты) аннотациями-ограничениями, а затем некий объект Validator умеет проверить экземпляр и вернуть список нарушений (violations). Это похоже на ситуацию с “контролёром в метро”: вы можете сколько угодно писать на двери “без билета нельзя”, но пока у вас нет контролёра, правило остаётся философией. Validator — это и есть “контролёр”, только для DTO.

В современном Spring Boot базовый пакет аннотаций выглядит так:

  • jakarta.validation.Valid — “проверь этот объект”
  • jakarta.validation.constraints.*@NotNull, @NotBlank, @Size, и так далее

Если вам вдруг попадаются импорты javax.validation.*, это обычно след “старого мира” (и в актуальном baseline курса лучше на этом не строить привычки).

Зависимости: spring-boot-starter-validation

Когда вы подключаете в Spring Boot “валидацию”, вы на самом деле подключаете сразу несколько слоёв: API-стандарт, реализацию и автоконфигурацию. Поэтому мы используем не “случайный набор библиотек”, а нормальный starter — так же, как мы делали со spring-boot-starter-webmvc.

В нашем курсе baseline для валидации задаётся зависимостью spring-boot-starter-validation. Внутри неё (через BOM Spring Boot) подтягивается и Jakarta Validation API, и провайдер (в нашем случае — Hibernate Validator). Важно понимать, что без провайдера аннотации остаются просто… аннотациями. Красивыми, бесполезными и очень уверенными в себе.

Минимально это выглядит так (кусок build.gradle.kts):

dependencies {
    // MVC + Jackson: контроллеры и конвертация JSON
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    // Bean Validation: API + провайдер (Hibernate Validator) + автонастройка
    implementation("org.springframework.boot:spring-boot-starter-validation")
}

Обратите внимание на “мораль” этого фрагмента: валидатор — такая же инфраструктурная часть приложения, как MVC и Jackson. Мы не создаём Validator руками, не собираем “свой велосипед проверки строк”, а пользуемся тем, что Spring Boot умеет настроить автоматически.

И ещё один нюанс, который спасает время новичкам: если вы уже разметили DTO аннотациями, но starter не подключён, вы будете смотреть на API и думать, что “@Valid не работает”. Он работает. Просто проверять нечем.

3. Validator в Spring-контексте

Валидация в Spring Boot — не “магия без сущности”, а вполне конкретный объект, который можно получить как зависимость. Это полезно психологически: как только вы один раз увидели Validator в конструкторе, мозг перестаёт воспринимать @Valid как заклинание.

В проекте Task Tracker API мы можем (чисто для понимания механики) внедрить jakarta.validation.Validator в любой Spring-компонент. Например, в небольшой сервис-диагност:

package com.example.tasktracker.domain.service;

import jakarta.validation.Validator;
import org.springframework.stereotype.Service;

@Service
public class ValidationInspector {

    // Это тот самый Bean Validation "движок", который умеет читать аннотации на DTO
    private final Validator validator;

    public ValidationInspector(Validator validator) {
        // Spring сам положит в конструктор настроенный валидатор из контекста
        this.validator = validator;
    }
}

Теперь можно вызвать его вручную и посмотреть, что происходит. Для механики Validator снова возьмём сокращённый фрагмент request DTO: нам достаточно увидеть пару базовых constraints, поэтому assigneeName и priority здесь опущены.

package com.example.tasktracker.api.dto.request;

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

public record TaskCreateRequest(
        // Title обязателен и не может быть строкой из одних пробелов
        @NotBlank
        // Ограничиваем длину, чтобы контракт был предсказуемым
        @Size(min = 3, max = 120)
        String title,

        // Description необязателен, но мы ограничиваем максимальный размер
        @Size(max = 2000)
        String description
) {}

И вот мини-метод, который проверяет DTO вручную:

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import jakarta.validation.Validator;

public class Demo {

    static void inspect(Validator validator) {
        // Передаём заведомо плохие данные: title = пробелы, description слишком длинный
        var req = new TaskCreateRequest(" ", "x".repeat(2100));

        // Валидатор читает аннотации на record-компонентах и возвращает набор нарушений
        var violations = validator.validate(req);

        // Ожидаем 2 нарушения: @NotBlank для title и @Size(max=2000) для description
        System.out.println(violations.size()); // 2
    }
}

Смысл такого примера не в том, чтобы всегда делать validator.validate() руками (в контроллере нам это чаще не нужно), а в том, чтобы увидеть: правила живут на DTO, а движок проверки один и тот же — независимо от того, запускаете ли вы его автоматически через Spring MVC или вручную.

4. Валидация @RequestBody в MVC

Jackson, потом проверки

Самая частая путаница у новичков звучит так: “раз я поставил @NotBlank, значит Spring проверяет мой JSON”. Нет. Spring проверяет уже созданный Java-объект, который получился после чтения тела запроса. То есть сначала должен сработать HttpMessageConverter (обычно Jackson-конвертер), и только потом — Bean Validation.

Это очень важное инженерное понимание, потому что оно позволяет вам предсказать поведение API в негативных сценариях. Если JSON сломан синтаксически или типы не сходятся, DTO может даже не появиться — и, соответственно, валидации “не над чем работать”. Если же DTO успешно создан, но значения нарушают constraints — вот тогда валидация вступает в игру.

Посмотрим на схему (упрощённо, но достаточно честно для Junior):

sequenceDiagram
    participant Client as HTTP client
    participant MVC as Spring MVC
    participant Conv as "HttpMessageConverter (Jackson)"
    participant Val as "Validator (Bean Validation)"
    participant Ctrl as TaskController
    participant Svc as TaskService

    Client->>MVC: "POST /api/v1/tasks (JSON body)"
    MVC->>Conv: "read body -> TaskCreateRequest"
    Conv-->>MVC: "DTO object (или ошибка чтения)"
    MVC->>Val: "validate(dto) если аргумент помечен @Valid"
    alt violations есть
        MVC-->>Client: "400 Bad Request (контроллер не вызван)"
    else ok
        MVC->>Ctrl: "вызвать create(dto)"
        Ctrl->>Svc: "taskService.create(dto)"
        Svc-->>Ctrl: "response DTO"
        Ctrl-->>Client: "201 Created + JSON"
    end

Здесь есть две принципиальные точки “стоп”:

Первая — на этапе конвертации тела запроса в DTO. Если тело нельзя прочитать, дальше идти просто некуда, потому что параметр метода контроллера не соберётся.

Вторая — на этапе валидации. DTO уже есть, но он “плохой” по правилам контракта.

Для нас сегодня главное: Bean Validation относится ко второму случаю, а не к первому.

Malformed JSON и нарушенные constraints

В API-дизайне очень легко смешать все плохие запросы в одну корзину “400 и всё”. Но даже на уровне понимания механики важно различать два семейства проблем: “не смогли прочитать” и “прочитали, но не приняли”.

Представим два запроса к POST /api/v1/tasks.

Первый — JSON сломан синтаксически (пропущена кавычка, лишняя запятая, и т.д.). Например:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json

{
  "title": "Buy milk,
  "description": "Remember lactose-free"
}

Здесь HttpMessageConverter не сможет даже построить TaskCreateRequest. Валидация не запускается, потому что объекта нет.

Второй — JSON валиден, DTO собрался, но значения “плохие” по правилам:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json

{
  "title": " ",
  "description": "x"
}

Теперь конвертация успешна (это нормальные строки), но @NotBlank на title говорит: “строка из пробелов — не считается”. И вот здесь уже включается Bean Validation.

Почему это важно? Потому что дальше по курсу, когда мы будем строить единый error contract, эти два случая будут относиться к разным категориям ошибок. Сегодня мы пока не углубляемся в формат ответа, но механика должна быть ясна уже сейчас: разные “точки поломки” рождают разные ошибки.

Провал валидации и остановка до контроллера

Один из самых полезных эффектов “валидируемого контроллера” — это то, что метод контроллера может вообще не быть вызван, если вход не прошёл проверку. И это не баг, а фича: контроллер и сервис не обязаны иметь дело с мусорным входом, если мы заранее договорились, что вход должен соответствовать контракту.

Как это ощущается на практике? Очень просто. У вас есть метод:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

public class TaskController {

    @PostMapping
    public TaskDetailsResponse create(
            // Говорим Spring MVC: "после сборки DTO прогнать Bean Validation"
            @Valid
            // Говорим Spring MVC: "взять тело запроса и сконвертировать в DTO"
            @RequestBody TaskCreateRequest request
    ) {
        // Если request не прошёл проверку, выполнение сюда вообще не дойдёт
        return null; // здесь был бы вызов сервиса
    }
}

Если request невалиден, Spring не заходит внутрь create(...). И это можно “поймать” даже банально логом (хотя логами злоупотреблять не будем): вы не увидите сообщений из тела метода, потому что поток обработки завершится раньше.

А что именно происходит вместо этого? Spring формирует негативный сценарий: возникает исключение, связанное с валидацией аргумента метода, и дальше оно превращается в HTTP-ответ. Сегодня нам достаточно запомнить одно: @Valid — это не “проверить и пойти дальше как ни в чём не бывало”. Это “проверить и, если плохо, остановить запрос”.

5. Сквозной пример: DTO и @Valid

Когда стек уже понятен, итоговый flow выглядит довольно коротко. Request DTO несёт constraints, контроллер ставит @Valid, и сервис получает уже проверенный объект. Здесь нам важен именно этот входной турникет: форма ответа не меняет механику валидации.

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        // До сервиса дойдёт либо валидный DTO, либо запрос остановится раньше
        this.taskService = taskService;
    }

    @PostMapping
    public ResponseEntity<Void> create(
            // После сборки DTO Spring MVC запускает Bean Validation
            @Valid @RequestBody TaskCreateRequest request
    ) {
        taskService.create(request);
        return ResponseEntity.status(201).build();
    }
}

Этого уже достаточно, чтобы увидеть связку слоёв: JSON сначала превращается в TaskCreateRequest, потом проверяется по constraints, и только после этого управление доходит до taskService.create(...). Если DTO не собрался или не прошёл проверку, контроллерный метод не будет вызван.

6. Типичные ошибки при валидации

Ошибка №1: аннотации на DTO есть, а проверка “как будто не работает”.
Чаще всего причина банальна: либо не подключён spring-boot-starter-validation, и тогда в приложении нет нормального провайдера, либо на аргументе контроллера забыли поставить @Valid. DTO сам по себе не начнёт проверяться “телепатически”: ему нужен запуск проверки в нужной точке pipeline.

Ошибка №2: ожидание, что Bean Validation поймает malformed JSON.
Если JSON синтаксически битый или типы не читаются, до DTO дело не дойдёт. Это задача этапа HttpMessageConverter, а не валидатора. Отсюда типичная путаница: разработчик присылает кривой JSON, получает ошибку, видит статус 400 и думает “это моя @NotBlank сработала”. Нет, это другой класс проблем и другая точка остановки.

Ошибка №3: неправильные импорты (javax.validation.* вместо jakarta.validation.*).
В актуальной линии Spring Boot и Spring Framework мир уже живёт в jakarta.*. Если вы случайно тянете старые импорты из подсказок IDE или из старых статей, можно получить странные эффекты: от проблем сборки до ситуации “аннотация вроде есть, но не та”. В учебном проекте лучше сразу приучиться к правильным пакетам.

Ошибка №4: попытка спрятать сложную бизнес-логику в constraints.
Есть соблазн: “а давайте регуляркой проверим вообще всё” или “а давайте через @Pattern запретим что угодно”. В этом дне мы учимся базовой структурной валидации (обязательность, длины, диапазоны, простой формат). Как только правило требует знания состояния приложения (например, “нельзя создавать задачу с title, который уже существует” или “нельзя менять архивную задачу”), это уже не задача базового DTO constraints.

Ошибка №5: ручная проверка и автоматическая проверка одновременно.
Иногда разработчик добавляет @Valid, но оставляет в сервисе пачку if (...) throw ... на те же самые поля. В результате правило начинает жить в двух местах, и рано или поздно лимиты расходятся: DTO говорит max 120, сервис проверяет max 100, клиент плачет, вы — тоже. В базовом слое валидации лучше выбрать один главный источник истины для структурных правил — request DTO.

1
Задача
Spring REST & MVC, 15 уровень, 1 лекция
Недоступна
Демонстрация `Validator` через отдельный endpoint
Демонстрация `Validator` через отдельный endpoint
1
Задача
Spring REST & MVC, 15 уровень, 1 лекция
Недоступна
Разница между ошибкой чтения body и ошибкой Bean Validation
Разница между ошибкой чтения body и ошибкой Bean Validation
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ