JavaRush /Курсы /Spring REST & MVC /Каскадная валидация @Valid...

Каскадная валидация @Valid в DTO

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

1. Проблема: вложенные DTO и @Valid

Пока request DTO плоский, кажется, что мы уже всё поняли: поставили @Valid на @RequestBody, добавили constraints — и Spring сам отсекает мусор. Но как только в DTO появляется вложенный объект (например, отдельный блок text, author, details), у новичков почти всегда возникает ожидание: “ну я же валидирую внешний DTO — значит внутри всё тоже проверится”. И вот тут начинается сюрприз.

Bean Validation по умолчанию не бродит по вашим объектам рекурсивно просто потому, что “там же внутри ещё что-то лежит”. Для него вложенный объект без специальных указаний — всего лишь значение поля, не более. Валидатор не будет угадывать, надо ли ему залезть внутрь, сколько уровней вложенности пройти и не превратить ли он ваш запрос в бесконечную экскурсию по объектному графу. Валидация должна быть управляемой: вы явно говорите, где именно включается “проверяем и внутренности тоже”.

Представьте, что вы пришли на паспортный контроль, а на входе проверяют только вашу обложку паспорта. Внутренние страницы проверят только если вы отдельно попросите инспектора открыть документ. @Valid на внешнем объекте — это “проверяем паспорт как объект”, но “листать страницы” — отдельная команда.

2. Модель проверки: текущий уровень

Чтобы не жить в мире магии, полезно представить себе простую модель: Bean Validation берёт объект и смотрит на его свойства (поля, геттеры, record components), на которые навешаны constraints. Он проверяет ровно то, что видно “на этом этаже”. Если поле — строка, число или дата, всё понятно: есть @NotBlank, @Size, @Min — проверяем. Если поле — другой объект, то без каскада валидатор воспринимает его примерно как “чёрный ящик”: значение либо null, либо “какой-то объект”. И всё.

Это сделано не из вредности, а из здравого инженерного смысла. В реальном приложении объектный граф может быть большим, циклическим и вообще не предназначенным для полного обхода. Если бы валидатор по умолчанию залезал во всё подряд, он мог бы внезапно начать валидировать половину доменной модели, а вы бы потом удивлялись, почему один маленький HTTP-запрос вдруг стал дорогим, как налоговая декларация на 500 страниц.

Вот небольшая схемка того, что происходит “без каскада”:

flowchart TD
    A["TaskDraftRequest (root)"]
    B["text: TaskTextBlockRequest (nested)"]
    C["title constraint"]
    D["description constraint"]

    A --> B
    B --> C
    B --> D

    style B fill:#f7f7f7,stroke:#999
    style C fill:#f7f7f7,stroke:#999
    style D fill:#f7f7f7,stroke:#999

    note1["Без каскада валидатор проверит только constraints на A. Внутрь B он не пойдёт."]

То есть: корневой объект проверили, увидели, что у него на поле text нет constraint’ов, и успокоились. А то, что внутри text есть @NotBlank на title, валидатор “не увидел”, потому что вы не включили обход внутрь.

3. Каскадная валидация и @Valid на поле

Каскадная, она же “вложенная”, валидация включается не автоматически, а через аннотацию @Valid на том месте, откуда вы хотите “провалиться” внутрь. И вот здесь важно поймать тонкость, которую почти все путают в начале.

@Valid на параметре контроллера (@Valid @RequestBody ...) говорит Spring: “проверь корневой объект перед тем, как запускать метод контроллера”. Но это не означает “проверь вообще всё, что там внутри когда-нибудь встретится”. Это означает: “проверь корень, а дальше — следуй правилам каскада, которые описаны в самом DTO”.

Именно поэтому вложенный объект нужно помечать отдельно: вы явно указываете, что поле text (или любое другое) должно быть валидировано как объект со своими правилами.

Сравните два варианта. Сначала “дырявый” DTO, где внутренний объект не валидируется:

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

public record TaskTextBlockRequest(
        // Заголовок: обязателен и имеет ограничения по длине
        @NotBlank
        @Size(min = 3, max = 120)
        String title,

        // Описание: необязательное, но ограничиваем максимальную длину
        @Size(max = 2000)
        String description
) {}
public record TaskDraftRequest(
        // Вложенный объект есть, но каскадная валидация НЕ включена
        TaskTextBlockRequest text
) {}

Здесь TaskTextBlockRequest сам по себе отлично размечен, но TaskDraftRequest не включает каскад. Итог: корневой DTO может пройти проверку, даже если title внутри пустой или слишком короткий.

А теперь включаем каскадную валидацию:

import jakarta.validation.Valid;

public record TaskDraftRequest(
        // Включаем каскадную валидацию для вложенного блока
        @Valid
        TaskTextBlockRequest text
) {}

Теперь, когда Spring валидирует TaskDraftRequest, Bean Validation увидит на поле text маркер “внутрь можно и нужно” и выполнит проверку TaskTextBlockRequest как вложенного объекта.

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

4. Роли @NotNull и @Valid

Когда студенты впервые видят @Valid на вложенном поле, почти всегда появляется следующий логичный, но неверный шаг: “а раз @Valid там стоит, значит он и проверит, что объект вообще передан”. Нет. @Valid не проверяет “наличие”. Он проверяет “содержимое”, и то — только если объект существует (то есть не null). Если объект null, каскадная валидация просто не сработает, потому что “внутрь” идти некуда.

Поэтому у вложенного блока обычно две независимые задачи:

Первая задача — убедиться, что блок вообще пришёл. За это отвечает @NotNull.

Вторая задача — убедиться, что если блок пришёл, то он корректен внутри. За это отвечает @Valid.

Вместе это выглядит так:

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;

public record TaskDraftRequest(
        // Блок обязателен по контракту
        @NotNull
        // И его внутренние поля тоже должны быть проверены
        @Valid
        TaskTextBlockRequest text
) {}

Чтобы это закрепилось, полезно посмотреть на “матрицу смыслов”:

Аннотация на вложенном поле Что проверяет Что НЕ проверяет
@NotNull что вложенный объект передан что внутри него валидные поля
@Valid что вложенный объект валиден по своим constraints что объект вообще существует (не null)

И вот почему эти две аннотации нельзя подменять друг другом: если вы оставите только @NotNull, то вы гарантируете наличие блока, но легко пропустите пустой title внутри. Если вы оставите только @Valid, то вы проверите поля внутри, но отсутствие блока text будет считаться “нормальным” и не вызовет ошибки.

На уровне контракта API это два разных негативных сценария. “Блок не передали вообще” обычно означает, что запрос структурно неполный. А “передали, но внутри пусто” означает, что запрос структурно есть, но данные плохие. Клиенту важно получать ошибку в обоих случаях.

Можно даже визуализировать на JSON. Вот запрос, где вложенный блок отсутствует:

{
  "text": null
}

Такой запрос поймает @NotNull, но @Valid сам по себе не спасёт.

А вот запрос, где блок есть, но title невалидный:

{
  "text": {
    "title": "  ",
    "description": "..."
  }
}

Такой запрос поймает каскадная валидация через @Valid, потому что ограничения @NotBlank/@Size живут внутри TaskTextBlockRequest.

5. Пример в контроллере

Чтобы связать теорию с реальным Spring MVC поведением, возьмём упрощённый create-endpoint, который принимает вложенный DTO. Да, в нашем каноническом Task Tracker API create-запрос может быть плоским, но нам сейчас важно увидеть механику вложенной валидации на понятном домене “задача с текстовым блоком”. Сама идея такая: контроллер по-прежнему получает один @RequestBody, просто этот body внутри устроен как “объект в объекте”.

DTO мы уже показали выше, а вот как выглядит контроллерный метод с точки зрения валидации:

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

public class TaskController {

    @PostMapping("/api/v1/tasks")
    public ResponseEntity<Void> createTask(
            // @Valid запускает Bean Validation ДО входа в метод контроллера
            @Valid @RequestBody TaskDraftRequest request
    ) {
        // Если request невалиден, до этой строки управление не дойдёт
        return ResponseEntity.status(201).build();
    }
}

Смысл здесь в одном: @Valid на параметре говорит Spring “проверь request перед тем, как выполнять метод”. И если request невалиден, Spring не пойдёт внутрь createTask вообще. Это очень важное ощущение для начинающего: вы не обязаны писать if-проверки в контроллере, чтобы защищаться от пустых title. Валидация на границе API отсекает мусор заранее.

Но — и это центральная тема сегодняшней лекции — глубина этой проверки зависит от того, включили ли вы каскад внутри DTO. Если вы забыли @Valid на поле text, то корневой TaskDraftRequest может пройти проверку, и метод контроллера запустится, даже если внутри text.title пустая строка. А дальше уже сервисный слой будет вынужден разбираться с входом, который вы хотели отсеять на границе. Это и есть “тихая дыра” в контракте: формально валидация есть, но реально часть данных без правил.

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

sequenceDiagram
    participant C as Client
    participant MVC as Spring MVC
    participant V as Validator
    participant CTRL as TaskController

    C->>MVC: POST /api/v1/tasks + JSON
    MVC->>V: validate(TaskDraftRequest)
    V-->>MVC: "violations? зависит от @Valid на поле text"
    alt violations found
        MVC-->>C: 400 Bad Request (method not called)
    else ok
        MVC->>CTRL: createTask(request)
        CTRL-->>C: 201 Created
    end

Эта диаграмма — почти вся лекция в одном кадре. Валидация — это фильтр перед входом в метод. И @Valid на вложенном поле определяет, станет ли фильтр “многоступенчатым” или останется “проверяем только поверхность”.

6. Контракт API: присутствие vs валидность

Снаружи API ваш request DTO — это обещание клиенту: какие поля существуют, какие обязательны, какие ограничения у значений. Когда DTO становится вложенным, у вас появляется два уровня “качества данных”. На верхнем уровне проверяется наличие/формат самого блока, а на внутреннем — корректность полей внутри блока. Если вы забываете каскад, вы фактически говорите клиенту: “передай мне объект text как хочешь, я всё равно приму”. Даже если вы в коде вложенного DTO красиво написали @NotBlank, без @Valid это останется декоративной наклейкой. Красиво, но не работает.

И здесь опасность не только в том, что “в сервис прилетит мусор”. Хуже то, что поведение становится непредсказуемым. Клиент может думать, что сервер проверит title, потому что вы когда-то это документировали или показывали в примерах, а сервер — молча пропускает пустую строку. В итоге ошибка проявится где-нибудь глубже: например, в логике маппинга, при формировании ответа, при сортировке или вообще при попытке “отрисовать” это поле где-нибудь в UI. Для backend это классическая ситуация “оно должно было упасть раньше”.

Если держать в голове дисциплину “валидация на границе API”, то правило простое: как только вы видите вложенный DTO, у вас автоматически появляются два вопроса. Первый: должен ли этот блок быть обязательным (тогда @NotNull). Второй: должны ли проверяться его внутренние поля (почти всегда да, тогда @Valid). И это не про аннотации ради аннотаций — это про то, чтобы ваш API вел себя одинаково на всех входах, а не “как повезёт”.

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

Ошибка №1: @Valid стоит только на @RequestBody, а на вложенном поле его нет.
Это самая частая ловушка. Вы честно включили валидацию в контроллере и даже аккуратно размечали constraints во вложенном DTO, но забыли единственный “включатель” каскада. В результате корневой объект валидируется, а внутренности остаются без проверки. Снаружи всё выглядит так, будто валидация есть, но на практике часть входа проходит “как есть”.

Ошибка №2: на вложенном поле стоит @Valid, но забыли @NotNull, и отсутствующий блок считается нормой.
Когда вложенный блок обязателен по контракту, отсутствие этого блока должно быть ошибкой. Но @Valid не про “обязательность”; если значение null, валидатор просто не пойдёт внутрь и не скажет ничего. Поэтому запрос может неожиданно считаться валидным, а уже дальше вы получите NullPointerException или “странные” ошибки в сервисе. Часто это выглядит как “почему у меня валидация не сработала”, хотя она сработала ровно так, как её попросили.

Ошибка №3: ожидание, что @NotNull на вложенном поле автоматически проверит содержимое.
Иногда делают наоборот: ставят только @NotNull, думая, что “раз объект обязателен — значит его поля тоже будут проверены”. Но @NotNull говорит ровно одну вещь: “значение не должно быть null”. Объект может быть не null, но при этом внутри иметь пустые строки, неверные длины и прочие радости. То есть @NotNull защищает только от отсутствия блока, но не от плохих данных в нём.

Ошибка №4: constraints живут во внутренней модели приложения, а request DTO остаётся “голым”.
Это методическая ошибка: вы можете сколько угодно размечать внутренний domain.model.TaskTextBlock, но если контроллер принимает request DTO без ограничений, граница API остаётся дырявой. В этом курсе мы специально держим правила входа на request DTO, потому что именно они являются частью публичного контракта. Внутренняя модель может меняться, а контракт должен быть предсказуемым.

Ошибка №5: смешение смыслов “пустое поле” и “объекта нет вообще”.
Для начинающего разработчика это часто выглядит одинаково: “ну нет же данных”. Но для API это разные ситуации. Если нет вложенного объекта — это один тип ошибки контракта. Если объект есть, но внутри пустые поля — это другой. И если вы неправильно комбинируете @NotNull и @Valid, то эти сценарии начинают сливаться в странное поведение, где часть ошибок исчезает, а часть вылезает позже и не там, где вы её ждёте.

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