1. Проблема: вкладені DTO та @Valid
Поки DTO запиту плоский, здається, що ми вже все зрозуміли: поставили @Valid на @RequestBody, додали обмеження — і Spring сам відсікає зайве. Але щойно в DTO з’являється вкладений об’єкт, наприклад окремий блок text, author чи details, у початківців майже завжди виникає очікування: «Я ж валідую зовнішній DTO — отже, всередині все теж перевіриться». І ось тут починаються сюрпризи.
Bean Validation за замовчуванням не обходить ваші об’єкти рекурсивно лише тому, що «там же всередині ще щось лежить». Для нього вкладений об’єкт без спеціальних вказівок — це просто значення поля, не більше. Валідатор не вгадує, чи треба йому заглядати всередину, скільки рівнів вкладеності пройти і чи не перетворить він ваш запит на нескінченну екскурсію графом об’єктів. Валідація має бути керованою: ви явно кажете, де саме вмикається перевірка внутрішнього вмісту.
Уявіть, що ви прийшли на паспортний контроль, а на вході перевіряють лише обкладинку паспорта. Внутрішні сторінки подивляться тільки тоді, коли ви окремо попросите інспектора відкрити документ. @Valid на зовнішньому об’єкті — це «перевіряємо паспорт як об’єкт», а «гортати сторінки» — окрема команда.
2. Модель перевірки: поточний рівень
Щоб не жити у світі магії, корисно уявити просту модель: Bean Validation бере об’єкт і дивиться на його властивості — поля, гетери, record components — на які навішані обмеження. Він перевіряє рівно те, що видно на цьому поверсі. Якщо поле — рядок, число або дата, усе зрозуміло: є @NotBlank, @Size, @Min — перевіряємо. Якщо поле — інший об’єкт, то без каскаду валідатор сприймає його приблизно як чорну скриньку: значення або null, або «якийсь об’єкт». І все.
Це зроблено не зі шкідливості, а зі здорового інженерного глузду. У реальному застосунку граф об’єктів може бути великим, циклічним і взагалі не призначеним для повного обходу. Якби валідатор за замовчуванням залазив у все підряд, він міг би раптово почати перевіряти половину доменної моделі, а ви б потім дивувалися, чому один маленький HTTP-запит раптом став дорогим, як податкова декларація на 500 сторінок.
Ось невелика схема того, що відбувається без каскаду:
flowchart TD
A["TaskDraftRequest (корінь)"]
B["text: TaskTextBlockRequest (вкладений)"]
C["обмеження title"]
D["обмеження description"]
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["Без каскаду валідатор перевірить лише обмеження на A. Усередину B він не піде."]
Тобто кореневий об’єкт перевірили, побачили, що на полі text немає обмежень, і заспокоїлися. А те, що всередині 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 | що вкладений об’єкт валідний за своїми обмеженнями | що об’єкт узагалі існує (не null) |
І ось чому ці дві анотації не можна підміняти одна одною: якщо ви залишите лише @NotNull, то гарантуєте наявність блока, але легко пропустите порожній title усередині. Якщо ви залишите лише @Valid, то перевірите поля всередині, але відсутність блока text буде вважатися нормальною і не викличе помилки.
На рівні контракту API це два різні негативні сценарії. «Блок не передали взагалі» зазвичай означає, що запит структурно неповний. А «передали, але всередині порожньо» означає, що запит структурно є, але дані погані. Клієнту важливо отримувати помилку в обох випадках.
Можна навіть візуалізувати це на JSON. Ось запит, де вкладений блок відсутній:
{
"text": null
}
Такий запит спіймає @NotNull, але @Valid сам по собі не врятує.
А ось запит, де блок є, але title невалідний:
{
"text": {
"title": " ",
"description": "..."
}
}
Такий запит спіймає каскадна валідація через @Valid, бо обмеження @NotBlank/@Size живуть усередині TaskTextBlockRequest.
5. Приклад у контролері
Щоб зв’язати теорію з реальною поведінкою Spring MVC, візьмемо спрощений 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 Клієнт
participant MVC as Spring MVC
participant V as Валідатор
participant CTRL as TaskController
C->>MVC: POST /api/v1/tasks + JSON
MVC->>V: validate(TaskDraftRequest)
V-->>MVC: "порушення? залежить від @Valid на полі text"
alt знайдено порушення
MVC-->>C: 400 Bad Request (метод не викликано)
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, а на вкладеному полі його немає.
Це найчастіша пастка. Ви чесно ввімкнули валідацію в контролері й навіть акуратно розмітили обмеження у вкладеному DTO, але забули єдиний увімквач каскаду. У результаті кореневий об’єкт валідовується, а внутрішній вміст залишається без перевірки. Ззовні все виглядає так, ніби валідація є, але на практиці частина входу проходить як є.
Помилка №2: на вкладеному полі стоїть @Valid, але забули @NotNull, і відсутній блок вважається нормою.
Коли вкладений блок є обов’язковим за контрактом, його відсутність має бути помилкою. Але @Valid не про обов’язковість; якщо значення null, валідатор просто не піде всередину і нічого не скаже. Тому запит може несподівано вважатися валідним, а вже далі ви отримаєте NullPointerException або дивні помилки в сервісі. Часто це виглядає як «чому у мене валідація не спрацювала», хоча вона спрацювала рівно так, як її попросили.
Помилка №3: очікування, що @NotNull на вкладеному полі автоматично перевірить вміст.
Іноді роблять навпаки: ставлять лише @NotNull, думаючи, що «раз об’єкт обов’язковий — значить його поля теж будуть перевірені». Але @NotNull каже рівно одну річ: «значення не має бути null». Об’єкт може бути не null, але при цьому всередині мати порожні рядки, неправильні довжини та інші радощі. Тобто @NotNull захищає лише від відсутності блока, але не від поганих даних у ньому.
Помилка №4: обмеження живуть у внутрішній моделі застосунку, а request DTO залишається голим.
Це методична помилка: ви можете скільки завгодно розмічати внутрішній domain.model.TaskTextBlock, але якщо контролер приймає request DTO без обмежень, межа API залишається дірявою. У цьому курсі ми спеціально тримаємо правила входу на request DTO, бо саме вони є частиною публічного контракту. Внутрішня модель може змінюватися, а контракт має бути передбачуваним.
Помилка №5: змішування смислів «порожнє поле» і «об’єкта немає взагалі».
Для початківця це часто виглядає однаково: «ну немає ж даних». Але для API це різні ситуації. Якщо немає вкладеного об’єкта — це один тип помилки контракту. Якщо об’єкт є, але всередині порожні поля — це інший. І якщо ви неправильно комбінуєте @NotNull і @Valid, то ці сценарії починають зливатися в дивну поведінку, де частина помилок зникає, а частина вилізає пізніше і не там, де ви її чекаєте.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ