1. Стандартні constraints: основа
Коли починаєш працювати з Bean Validation, виникають дві крайнощі. Перша — намагатися вирішити взагалі все стандартними анотаціями, перетворюючи DTO на новорічну ялинку зі @NotBlank @Size @Pattern @Min @Max, а потім дивуватися, що бізнесові правила все одно «просочуються» в сервіс. Друга — навпаки: побачити першу незручність і одразу кинутися писати кастомний валідатор, ніби без @Constraint ваш проєкт «несерйозний». Насправді зрілий підхід — уміти діагностувати момент, коли стандартних constraints справді недостатньо, і розуміти, якого саме це обмеження: це ще input validation (отже, можна і треба посилювати контракт) чи вже предметне правило (отже, його місце в сервісі).
Тут важливіше не сам валідатор, а момент вибору. Наша мета — навчитися помічати сигнали, що контракт став складнішим, ніж можуть висловити стандартні анотації, і вчасно відрізняти input validation від предметного правила.
Що покривають стандартні анотації
Стандартні анотації Bean Validation хороші тим, що вони дуже «чесні»: ви дивитеся на поле — і одразу розумієте, які правила в нього є. Це як дорожні знаки: так, іноді їх багато, але зміст зчитується швидко, і головне — однаково для всіх. У реальному REST API це дає величезний бонус: ви фіксуєте вхідний контракт прямо в DTO, а не залишаєте його на милість випадкових перевірок у коді. Тому починати завжди варто саме з цього набору: @NotNull, @NotBlank, @Size, @Min, @Max, @Pattern та їхніх «родичів». Поки ваші правила вкладаються в «одне поле — одне обмеження», стандартних анотацій зазвичай вистачає з головою.
Нижче — приклад, де стандартний набір виглядає природно й не вимагає жодних акробатичних трюків. Це рівно той випадок, коли кастомна валідація буде не посиленням, а ускладненням «заради спортивного інтересу».
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
// DTO фіксує вхідний контракт: вимоги видно прямо з анотацій
public record TaskCreateRequest(
@NotBlank // заголовок обов’язковий: без нього задача безглузда
@Size(min = 3, max = 120) // обмежуємо довжину, щоб не приймати сміття і не ламати UI
String title,
@Size(max = 2000) // поле необов’язкове, але ми обмежуємо "розмір простирадла"
String description
) {}
Тут усе прозоро: title обов’язковий і має розумну довжину, description необов’язковий, але не нескінченний (інакше хтось одного дня вставить туди «Війну і мир» і скаже: «Ну ви ж самі дозволили…»).
Так само стандартні анотації чудово працюють із колекціями, коли правила прості й локальні: обмежити кількість елементів, обмежити довжину кожного елемента, заборонити порожні рядки. Зверніть увагу на важливу деталь: обмеження на елементи колекції пишеться «всередині» типу елемента.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
@Size(max = 10) // обмеження стосується всієї колекції (не більше 10 тегів)
List<@NotBlank // обмеження стосується елемента: тег не повинен бути порожнім
@Size(max = 30) // і не повинен бути надто довгим
String> tags
) {}
Це вже схоже на нормальний контракт: «не більше 10 тегів, кожен тег непорожній і не довший за 30 символів». І поки вам потрібно саме це — будь ласка, живіть спокійно: кастомні валідатори поки що не потрібні.
2. Сигнали, що стандартних constraints уже замало
Коли проєкт зростає, правила зазвичай ускладнюються не тому, що розробники люблять ускладнювати собі життя, а тому, що реальна предметна область починає ставити незручні запитання. І є кілька типових сигналів, за якими легко впіймати момент: «Окей, стандартні анотації закінчилися».
Перший сигнал — ручна перевірка починає повторюватися. Спочатку ви один раз написали if «на хвилинку», потім другий раз «ну тут схоже», потім третій раз «гаразд, потім винесемо». У цей момент у валідації з’являється друга «неофіційна» версія, і контракт перестає бути єдиним: частина правил живе в DTO, частина — в сервісі, частина — випадково в контролері. Це як мати два паспорти з різними датами народження: рано чи пізно хтось спитає, який справжній.
Наприклад, ось такий код виглядає як тимчасовий захід, але зазвичай стає постійним мешканцем сервісу:
import java.util.List;
public class TaskService {
public void validateTagsManually(List<String> tags) {
// Ручна валідація в сервісі часто означає дублювання того,
// що вже можна (і краще) виразити анотаціями в DTO
if (tags != null && tags.size() > 10) {
// Важливо: це демонстрація проблеми, а не "рекомендований" спосіб звітувати про помилки API
throw new IllegalArgumentException("Забагато тегів"); // тимчасово, чесно-чесно
}
}
}
Проблема тут навіть не в IllegalArgumentException (хоча це теж окрема історія), а в тому, що правило «максимум 10 тегів» уже може бути виражене через @Size(max = 10). Отже, якщо ми тримаємо if у сервісі, ми або дублюємо контракт, або не довіряємо йому. І обидва варіанти невдалі.
Другий сигнал — правило залежить одразу від кількох полів. Поки ви перевіряєте лише одне поле за раз, стандартні constraints вам допомагають. Але щойно з’являється правило типу «поле A пов’язане з полем B», анотації на окремому полі вже недостатньо. Типовий приклад у нашому проєкті — діапазон дат у критеріях пошуку: dueAfter і dueBefore. Кожна дата окремо може бути валідною, але їхнє поєднання — безглуздим.
import java.time.LocalDate;
// Тут проблема не в "валідності" окремої дати,
// а в узгодженості пари полів (dueAfter <= dueBefore)
public record TaskSearchCriteria(
LocalDate dueAfter,
LocalDate dueBefore
) {}
Якщо клієнт надішле 2030-01-01 і 2020-01-01, то обидві дати валідні як дати. Проблема не у форматі, а в логіці поєднання. І ось це вже дзвіночок: стандартних constraints тут не вистачає, бо їм нема на що спиратися — вони бачать поле, але не бачать об’єкт цілком.
Третій сигнал — правило стосується колекції як цілого, а не елементів. Це тонкий момент: Bean Validation доволі легко валідирує елементи списку, але властивості всієї колекції, як-от унікальність або взаємна узгодженість елементів, — це вже не стандартна історія. У нашому Task Tracker API теги мають бути унікальними без урахування регістру. Це означає, що ["bug", "BUG"] — поганий input, хоча кожен елемент окремо ідеально валідний.
Щоб відчути проблему, подивіться на наївну ручну перевірку унікальності. Вона часто з’являється першою — просто тому, що її швидко написати.
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class TagRules {
public static boolean hasDuplicatesIgnoreCase(List<String> tags) {
// Ідея: нормалізуємо рядок (тут лише lower-case) і збираємо в set
// Якщо унікальних менше, ніж початкових — є дублікати
Set<String> unique = new HashSet<>();
for (String tag : tags) {
// Спрощення прикладу: поки без trim() і без обробки null-елементів
unique.add(tag.toLowerCase()); // спрощення: поки без trim()
}
return unique.size() != tags.size();
}
}
Навіть у такому простому варіанті ви бачите, що це вже не @Size і не @Pattern. Тут з’являються нормалізація (toLowerCase, а потім ще й trim), логіка порівняння, і правило стосується не одного елемента, а набору.
Щоб це не перетворилося на відчуття, зафіксуємо все у вигляді невеликої таблиці (вона зручна як діагностичний інструмент — буквально як контрольний список перед тим, як писати новий код).
| Ситуація в коді | Як виглядає в проєкті | Чому стандартних анотацій мало | Що це означає |
|---|---|---|---|
| Перевірка повторюється if-ами в різних місцях | «забагато тегів» перевіряють і в одному сервісі, і в іншому | контракт перестає бути єдиним і передбачуваним | правило треба «повернути» в шар валідації як частину вхідного контракту |
| Правило залежить від 2+ полів | dueAfter і dueBefore мають утворювати нормальний діапазон | анотація на окремому полі не бачить сусідні поля | це кандидат на cross-field (object-level) перевірку |
| Правило стосується колекції як цілого | унікальність тегів без урахування регістру | валідація елементів не розв’язує задачу унікальності | це кандидат на collection-level правило, часто через кастомний constraint |
Зверніть увагу: у всіх цих випадках ми ще не говоримо про бізнес і стан системи. Ми говоримо лише про вхідний payload і його внутрішню узгодженість. А це означає, що ми поки залишаємося в зоні input validation.
3. Input validation і бізнес-правила
Навіть якщо правило складне, це ще не привід одразу тягнути його в ConstraintValidator. Спочатку важливо відокремити дві різні історії: input validation відповідає за те, що видно з самого запиту, а business validation — за те, що стає зрозуміло лише після читання поточного стану ресурсу.
Зручно швидко перевіряти правило двома запитаннями. Перше: чи можна перевірити його, маючи лише вхідний JSON, query-параметри або path-параметри. Друге: чи зміниться результат, якщо завтра стан системи зміниться, а запит залишиться тим самим. Якщо на перше відповідь «так», а на друге — «ні», ми залишаємося в input validation. Якщо без поточного стану ресурсу не розібратися — це вже сервісний шар.
Наприклад, унікальність тегів усередині одного payload — це input validation: нам достатньо самого списку рядків. А правило «архівну задачу не можна змінювати» — уже business validation, бо без поточного статусу задачі його не перевірити. Поки нам достатньо не переплутати ці два класи правил: не кожне складне правило потрібно оформлювати як Bean Validation.
З такою діагностикою вже простіше зрозуміти, коли потрібен кастомний constraint, а коли правило має лишитися поруч із операцією.
4. Мінідіагностика перед кастомом
Коли ви впіймали один із сигналів, рука зазвичай тягнеться до дії: «Окей, пишемо кастомну анотацію». Але краще на хвилину зупинитися і провести маленьку діагностику — буквально як інженер: «А точно це потрібно?». Бо кастомна валідація — корисний інструмент, але без дисципліни вона легко перетворюється на магію.
Я зазвичай пропоную студентам такий підхід: спочатку спробувати виразити правило стандартними анотаціями або їхньою композицією. Іноді виявляється, що вам не потрібен кастомний валідатор, а просто потрібні правильно поставлений @Size, @NotBlank і валідація елементів колекції. Потім перевірити, чи не є правило прихованим бізнесом, тобто чи не залежить воно від поточного стану ресурсу. І тільки якщо обидва шляхи не підходять, ви чесно визнаєте: «Так, це input validation, але воно складніше, ніж стандартні анотації».
Щоб було простіше, тримайте в голові ось таку схему ухвалення рішення (вона не про Spring, вона про здоровий глузд — Spring тут лише інструмент):
flowchart TD
A["Є правило для вхідного DTO"] --> B{"Виражається стандартними constraints?"}
B -->|Так| C["Залишаємо стандартні анотації (композиція, вкладена валідація)"]
B -->|Ні| D{"Потрібно знати стан системи (ресурс, статус, наявність у сховищі)?"}
D -->|Так| E["Це business validation. Правило живе в сервісі."]
D -->|Ні| F["Це input validation, складніше за стандартні анотації. Кандидат на кастомну валідацію."]
Ця схема корисна тим, що вона захищає вас від двох поганих рішень. Вона не дає вам написати кастомний валідатор там, де достатньо стандартного @Size. І вона не дає вам забрати бізнес-правила в DTO під виглядом «красивої анотації».
На практиці, якщо ви тримаєте цю діагностику в голові, шар валідації стає читабельним. DTO лишається «паспортом контракту», сервіс — місцем для предметних заборон, і ви менше ловите сюрпризів на рев’ю чи під час налагодження.
5. Типові помилки при виборі валідації
Помилка №1: писати кастомний валідатор при першій незручності.
Іноді студент бачить, що «не виходить однією анотацією», і одразу йде в @Constraint. Але дуже часто це означає не «потрібен кастомний валідатор», а «потрібно нормально скомпонувати стандартні обмеження». Наприклад, обмеження кількості тегів і довжини тега чудово виражається стандартними анотаціями й не потребує нічого кастомного.
Помилка №2: вважати, що будь-яка «невдала операція» — це невалідний input.
Запит може бути ідеально валідним, але операція заборонена доменними правилами. Якщо ви почнете відносити такі випадки до input validation, у вас з’явиться змішування шарів: DTO почне «знати» про стан задач, а сервіс перетворюватиметься на тонкий проксі над валідаторами. Це виглядає красиво лише перші два дні, а потім стає дорогим у підтримці.
Помилка №3: дублювати правило і в DTO, і в сервісі «про всяк випадок».
Дублювання валідації майже завжди закінчується тим, що дві копії розходяться. Одна перевіряє max = 10, інша — max = 12, і ось у вас лотерея: де саме «зловлять» помилку. Краще вибрати одне місце істини для кожного правила: або DTO (якщо це input validation), або сервіс (якщо це business validation).
Помилка №4: плутати «валідацію» і «нормалізацію».
Дуже часта ситуація з тегами: потрібно trim() і перевіряти унікальність без урахування регістру. Нормалізація — це не завжди валідація. Іноді це підготовка даних до обробки. Якщо ви сховаєте нормалізацію всередину випадкового if у сервісі, ви отримаєте правило, яке складно побачити і легко порушити. Валідаційний шар хороший тим, що робить вимоги явними; нормалізація має бути не менш явною.
Помилка №5: перетворювати контракт на «чорну скриньку».
Коли з’являється бажання зробити універсальну анотацію на кшталт @ValidTaskPayload і сховати туди все підряд — це майже завжди погана ідея. На рівні читання DTO стає незрозуміло, що саме перевіряється. Хороший контракт читається зверху вниз: поле → анотації → зрозумілий зміст.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ