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, то эти сценарии начинают сливаться в странное поведение, где часть ошибок исчезает, а часть вылезает позже и не там, где вы её ждёте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ