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

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

Spring REST & MVC
Рівень 16 , Лекція 0
Відкрита

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, то ці сценарії починають зливатися в дивну поведінку, де частина помилок зникає, а частина вилізає пізніше і не там, де ви її чекаєте.

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