1. Стек валідації: склад і роль
Якщо ви колись бачили код, де в одному місці перевіряють title, в іншому — assigneeName, а в третьому раптом ще й priority, то ви вже стикалися з «валідацією як імпровізацією». Стек валідації потрібен, щоб перетворити цю імпровізацію на системний механізм: чіткі правила, один рушій перевірки та передбачуване місце, де запит зупиняється.
У контексті Spring Boot під стеком валідації ми розумітимемо набір компонентів, який дає змогу зробити так: ви описали обмеження (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.*, це зазвичай слід «старої епохи» (і в актуальній базовій конфігурації курсу краще на цьому не будувати звички).
Залежності: spring-boot-starter-validation
Коли ви підключаєте до Spring Boot валідацію, ви насправді підключаєте одразу кілька шарів: API-стандарт, реалізацію та автоконфігурацію. Тому ми використовуємо не «випадковий набір бібліотек», а нормальний starter — так само, як ми робили зі spring-boot-starter-webmvc.
У нашому курсі базовий рівень валідації задається залежністю 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-клієнт
participant MVC as Spring MVC
participant Conv as "HttpMessageConverter (Jackson)"
participant Val as "Валідатор (Bean Validation)"
participant Ctrl as TaskController
participant Svc as TaskService
Client->>MVC: "POST /api/v1/tasks (тіло JSON)"
MVC->>Conv: "прочитати тіло -> TaskCreateRequest"
Conv-->>MVC: "DTO-обʼєкт (або помилка читання)"
MVC->>Val: "validate(dto), коли аргумент позначено @Valid"
alt є порушення
MVC-->>Client: "400 Bad Request (контролер не викликано)"
else все гаразд
MVC->>Ctrl: "викликати create(dto)"
Ctrl->>Svc: "taskService.create(dto)"
Svc-->>Ctrl: "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": "Купити молоко,
"description": "Не забути про безлактозне молоко"
}
Тут HttpMessageConverter не зможе навіть побудувати TaskCreateRequest. Валідація не запускається, бо об’єкта немає.
Другий — JSON валідний, DTO зібрався, але значення «погані» за правилами:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": " ",
"description": "x"
}
Тепер конвертація успішна (це звичайні рядки), але @NotBlank на title каже: «рядок із пробілів не рахується». І ось тут уже вмикається Bean Validation.
Чому це важливо? Тому що далі по курсу, коли ми будуватимемо єдиний контракт помилок, ці два випадки належатимуть до різних категорій. Сьогодні ми поки що не заглиблюємося у формат відповіді, але механіка має бути зрозуміла вже зараз: різні точки поломки породжують різні помилки.
Провал валідації та зупинка до контролера
Один із найкорисніших ефектів «контролера, що валідовується», — це те, що метод контролера може взагалі не бути викликаний, якщо вхід не пройшов перевірку. І це не баг, а корисна особливість: контролер і сервіс не зобов’язані мати справу з брудним входом, якщо ми заздалегідь домовилися, що вхід має відповідати контракту.
Як це виглядає на практиці? Дуже просто. У вас є метод:
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
Коли стек уже зрозумілий, підсумковий потік виглядає досить коротко. Request DTO несе обмеження, контролер ставить @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 сам по собі не почне перевірятися «телепатично»: йому потрібен запуск перевірки в потрібній точці конвеєра.
Помилка №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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ