JavaRush /Курси /Spring REST & MVC /Як улаштований кастомний constraint

Як улаштований кастомний constraint

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

1. Практика: custom constraint

Коли ви вперше чуєте «кастомна валідація», легко уявити собі щось страшне: пів сторінки анотацій, загадкові generics і відчуття, ніби зараз ми випадково напишемо міні-Spring усередині Spring. Насправді кастомний constraint — це просто спосіб чесно сказати: «у мого поля є правило, яке не вкладається в стандартні @Size і @Pattern».

Якщо спростити до побутового рівня, стандартні анотації — це як базовий набір інструментів: викрутка, молоток, шестигранник. Поки ви збираєте звичайні меблі, усе добре. Але інколи трапляється «особливий» болт або правило на кшталт «значення мають бути унікальними без урахування регістру» — і ось тут вам потрібен власний інструмент, але маленький і спеціалізований, а не зварювальний апарат чи промисловий верстат.

Важливо зафіксувати межу: custom constraint — це все ще input validation. Він перевіряє те, що видно з самого значення поля (або значення параметра), не заглядаючи в сервіси, репозиторії та «поточний стан задачі». Тобто ніякої «перевірки архівності» або «чи існує задача у сховищі» всередині валідатора — такі правила вже залежать від стану системи й сюди не належать.

2. Склад custom constraint

Перед тим як писати код, корисно на хвилину зупинитися й зрозуміти архітектуру механізму. Кастомний constraint завжди складається з двох частин: анотації, яку ви ставите на поле чи параметр, і валідатора, який справді виконує перевірку. Анотація — це «ярлик правила», а валідатор — «виконавець», який знає, як це правило перевірити.

Схематично це виглядає так:

flowchart TD
    A["Поле DTO або параметр"] --> B["@YourConstraint"]
    B --> C["ConstraintValidator"]
    C --> D{"isValid?"}
    D -->|true| E["Валідація триває далі"]
    D -->|false| F["Створюється ConstraintViolation"]

Тут немає жодної магії рівня «Spring сам здогадається». Усе досить прямолінійно: Bean Validation бачить анотацію, розуміє, який клас валідатора їй відповідає, створює валідатор і викликає в ньому метод isValid(...).

І ось важливий момент: валідатор повертає boolean, а не кидає винятки і не робить System.out.println("Ой, помилка"). Повертаємо false — отримуємо constraint violation. Повертаємо true — перевірка вважається пройденою.

3. Анотація @UniqueTagsIgnoreCase

Зараз ми зробимо маленький, але корисний кастомний constraint для нашого проєкту: перевірку унікальності тегів без урахування регістру. За вимогами проєкту теги мають бути унікальними в межах однієї задачі, незалежно від регістру. Це правило не виражається стандартними анотаціями: @Size вміє лише «скільки», а @Pattern — лише «як виглядає», але не «не повторюйся».

Розмістимо constraint у пакеті, який архітектура проєкту для цього передбачає: com.example.tasktracker.domain.validation

Анотація

package com.example.tasktracker.domain.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

// Пов’язуємо анотацію з валідатором: саме цей клас викликатиме Bean Validation
@Constraint(validatedBy = UniqueTagsIgnoreCaseValidator.class)

// Дозволяємо ставити анотацію на поле, параметр і компонент record (важливо для record DTO)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})

// Анотація має бути доступна під час виконання, інакше валідатор просто не буде викликаний
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueTagsIgnoreCase {
    // Повідомлення за замовчуванням для помилки валідації
    String message() default "теги мають бути унікальними без урахування регістру";

    // Службові елементи Bean Validation: групи (часто не потрібні на початку, але мають бути)
    Class
  [] groups() default {};

    // Службові елементи Bean Validation: payload (розширення метаданих, зазвичай не використовуємо)
    Class
  [] payload() default {};
}

Тут важливо зрозуміти кожну частину, а не просто «скопіювати та забути».

@Constraint(validatedBy = ...) — це головний «дріт», який з’єднує анотацію й валідатор. Без нього анотація буде просто красивою наклейкою, яку ніхто не читає.

@Target(...) говорить, де взагалі можна ставити анотацію. Ми додали FIELD і PARAMETER, щоб використовувати constraint і на полях DTO, і на параметрах методів, якщо раптом захочемо. Додали RECORD_COMPONENT, тому що в курсі ми активно використовуємо record для DTO, і це робить застосування анотації передбачуванішим.

@Retention(RetentionPolicy.RUNTIME) обов’язковий із дуже простої причини: Bean Validation працює під час виконання. Якщо retention буде, наприклад, CLASS, то під час виконання анотації «ніби немає».

А ось ці три елементи:

String message() default "...";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};

— це стандартний «службовий хвіст» constraint-анотації. Він обов’язковий. Можна сприймати це як «технічну угоду»: будь-яка constraint-анотація має вміти надати повідомлення і підтримувати групування/пейлоади, навіть якщо ми поки не користуємося ними.

4. Валідатор UniqueTagsIgnoreCaseValidator

Анотація — це декларація правила, але не перевірка. Перевірка живе в класі, який реалізує ConstraintValidator. На цьому місці у новачків часто з’являється відчуття «зараз почнуться страшні generics», але насправді все дуже логічно: валідатор має сказати, яку анотацію він обслуговує і який тип значення він уміє перевіряти.

Валідатор

package com.example.tasktracker.domain.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class UniqueTagsIgnoreCaseValidator
        implements ConstraintValidator<UniqueTagsIgnoreCase, List<String>> {

    @Override
    public boolean isValid(List<String> tags, ConstraintValidatorContext ctx) {
        // null тут не вважаємо помилкою: обов’язковість задається окремо через @NotNull/@NotEmpty
        if (tags == null) return true;

        // TreeSet із CASE_INSENSITIVE_ORDER забезпечить унікальність без урахування регістру
        Set<String> unique = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);

        for (String tag : tags) {
            // null-елементи не валідуємо цим валідатором: це зона відповідальності анотацій на елементі списку
            if (tag == null) continue;

            // Нормалізуємо пробіли, щоб "bug" і " bug " вважалися одним і тим самим тегом
            if (!unique.add(tag.trim())) return false; // повтор — отже правило порушено
        }

        return true; // усі теги унікальні (без урахування регістру і після trim)
    }
}

Розберімо, що відбувається.

ConstraintValidator<UniqueTagsIgnoreCase, List<String>> означає: «Я валідатор для анотації @UniqueTagsIgnoreCase, і я вмію перевіряти значення типу List<String>». Якщо ви помилитеся в другому параметрі та напишете, наприклад, String, то анотація просто не зможе коректно застосуватися до списку тегів, і ви отримаєте дуже «доброзичливу» помилку… десь під час виконання, у найбільш невдалий момент.

Далі — найважливіший метод: isValid(...). Він отримує значення поля (tags) і контекст (ctx). Ми поки що використовуємо контекст лише як обов’язковий параметр, без тонкого налаштування.

null не зобов’язаний бути помилкою

Це важлива філософська й практична домовленість: більшість constraint-анотацій не відповідають за обов’язковість поля. Обов’язковість — це @NotNull/@NotBlank/@NotEmpty. Якщо поле tags у нас необов’язкове, то null — допустимо, і валідатор має спокійно сказати: «окей, мені нічого перевіряти».

Якби ми повернули false на null, це означало б: «поле обов’язкове», але ми не хочемо ховати це рішення всередині валідатора. Краще тримати обов’язковість окремо й явно.

Нормалізація через trim()

Ми перевіряємо унікальність за змістом. Якщо клієнт надіслав "bug" і " bug " — формально це різні рядки, але для людини це один і той самий тег, просто з пробілами. trim() — невелика нормалізація, яка робить правило кориснішим у реальному житті.

І так, це той самий момент, де важливо не перестаратися: валідатор не має перетворюватися на «нормалізатор усього світу». Він просто готує значення для конкретного правила.

5. Застосування в DTO

Писати кастомний constraint лише заради самого факту його існування — сумнівна розвага (приблизно як заводити окремий мікросервіс заради одного рядка коду). Але коли він справді потрібен, найприємніший ефект у тому, що правило стає видимим прямо в DTO. Тобто контракт читається очима: «ось поле, ось його обмеження», без стрибків у сервісний шар і пошуку істини по проєкту.

TaskCreateRequest

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

public record TaskCreateRequest(
        // Просте правило для рядка: обов’язковість + довжина
        @NotBlank
        @Size(min = 3, max = 120)
        String title,

        // Поле необов’язкове, обмежуємо лише розмір
        @Size(max = 2000)
        String description,

        // Правила для списку як для цілого (розмір + унікальність)
        @Size(max = 10)
        @UniqueTagsIgnoreCase
        // Правила для кожного елемента списку (NotBlank + довжина)
        List<@NotBlank @Size(max = 30) String> tags
) {}

Зверніть увагу на композицію. Тут три «рівні» перевірки, і кожен відповідає за свою частину:

@Size(max = 10) перевіряє розмір списку. Це стандартне правило, не треба винаходити велосипед.

@UniqueTagsIgnoreCase перевіряє список як ціле на унікальність. Це й є наша кастомна частина.

List<@NotBlank @Size(max = 30) String> tags перевіряє кожен елемент списку. Тобто якщо клієнт надішле ["", "bug"], ми спіймаємо порожній тег. Якщо надішле ["very-very-very-long-tag-..."], спіймаємо завелику довжину.

І завдяки цьому ми отримуємо «шаруватий пиріг» валідації: кожна анотація робить свою маленьку справу, а підсумковий контракт виглядає читабельно.

6. Нюанс, який економить нерви

Коли ви вперше пишете кастомний валідатор, дуже хочеться зробити його «найрозумнішим» і запхати в нього все: і null-перевірку, і перевірку довжини, і trim(), і допустимі символи, і, про всяк випадок, перевірку існування тега в базі… (якої у нас, до речі, немає). Рука тягнеться, бо «ну я ж уже у валідаторі».

Але в нормальному проєкті (і в навчальному теж) працює спокійніший підхід: валідатор відповідає за одне правило, а все інше залишається там, де й має бути. Для цього корисно пам’ятати просту таблицю:

Питання Де зазвичай вирішують
Поле обов’язкове? @NotNull, @NotBlank, @NotEmpty
Розмір рядка/списку? @Size, @Min, @Max
Формат рядка? @Pattern або окремий невеликий кастомний constraint
Унікальність елементів у межах одного поля? кастомний constraint (як сьогодні)
Чи можна виконати операцію з урахуванням стану ресурсу? сервісний шар (не валідатор)

Якщо тримати це в голові, ваші кастомні перевірки будуть маленькими, зрозумілими і не перетворяться на «чорну скриньку, яка робить усе».

7. Типові помилки під час custom constraint

Коли ви вперше починаєте писати власні constraint-анотації, помилки майже неминучі. І це нормально: мозок ще не звик до того, що анотація — це декларація, а валідатор — виконання. Головне — навчитися швидко впізнавати ці проблеми за симптомами, щоб не сидіти три години над питанням «чому не працює?».

Помилка №1: забули @Constraint(validatedBy = ...).
Зовні все виглядає красиво: анотація стоїть на полі, проєкт компілюється, а перевірка наче не запускається. Це дуже підступна ситуація, тому що ви чекаєте помилку, а її немає. Причина майже завжди одна: ви не зв’язали анотацію з валідатором, і Bean Validation просто не знає, хто має виконувати правило.

Помилка №2: неправильний @Target, особливо якщо ви використовуєте record.
Якщо constraint не можна поставити туди, куди ви намагаєтеся його поставити (наприклад, на record component), компілятор може сваритися, або анотація застосовуватиметься не туди. Додавайте ElementType.RECORD_COMPONENT, якщо ви валідуєте record DTO, і не соромтеся бути трохи явнішими — це той випадок, коли «явно» краще, ніж «загадково».

Помилка №3: валідатор кидає виняток замість false.
Іноді всередині isValid() пишуть щось на кшталт throw new IllegalArgumentException("duplicate"). У результаті ви перетворюєте звичайну помилку валідації на внутрішню помилку застосунку, і далі все поводиться дивно. Контракт Bean Validation простий: хочете сказати «не можна» — поверніть false.

Помилка №4: null вважається помилкою «за замовчуванням».
Якщо валідатор повертає false на null, ви неявно робите поле обов’язковим. Іноді це справді потрібно, але частіше це просто випадкова помилка. Гарне правило для старту: nulltrue, а обов’язковість задаємо окремою анотацією. Це робить DTO значно читабельнішим: одразу видно, обов’язкове поле чи ні.

Помилка №5: валідатор починає ходити в сервіс/репозиторій.
Це прямий шлях до змішування шарів. Щойно валідатор викликає сервіс, він перестає бути input validation і перетворюється на «шматок бізнес-логіки в несподіваному місці». У реальному проєкті це ще й проблема продуктивності та передбачуваності: validation може запускатися частіше, ніж ви думаєте. Тому тримаємо правило: валідатор перевіряє значення, а не стан системи.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ