JavaRush /Курси /Spring REST & MVC /Коли стандартних constraints замало

Коли стандартних constraints замало

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

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

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