1. Практика: custom constraint
Когда впервые слышишь «кастомная валидация», легко представить себе что-то страшное: полстраницы аннотаций, загадочные generics и ощущение, что сейчас мы случайно напишем мини-Spring внутри Spring. На самом деле кастомный constraint — это просто способ честно сказать: “у моего поля есть правило, которое не укладывается в стандартные @Size и @Pattern”.
Если упрощать до бытового уровня, то стандартные аннотации — это как базовый набор инструментов (отвёртка, молоток, шестигранник). Пока вы собираете обычную мебель, всё хорошо. Но иногда попадается «особенный» болт или правило вроде “значения должны быть уникальны без учёта регистра” — и вот тут вам нужен свой инструмент, но маленький и специализированный, а не сварочный аппарат и промышленный станок.
Важно зафиксировать границу: custom constraint — это всё ещё input validation. Он проверяет то, что видно из самого значения поля (или из значения параметра), не лазая в сервисы, репозитории и “текущее состояние задачи”. То есть никакой «проверки архивности» или «существует ли задача в хранилище» внутри валидатора — такие правила уже зависят от состояния системы и сюда не относятся.
2. Состав кастомного 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 "tags must be unique ignoring case";
// Служебные поля 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. Типичные ошибки при кастомных 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, вы неявно делаете поле обязательным. Иногда это действительно нужно, но чаще это просто случайная ошибка. Хорошее правило для старта: null → true, а обязательность задаём явно отдельной аннотацией. Это делает DTO гораздо более читаемым: у поля сразу видно, обязательно оно или нет.
Ошибка №5: валидатор начинает ходить в сервис/репозиторий.
Это прямой путь к смешению слоёв. Как только валидатор вызывает сервис, он перестаёт быть input validation и превращается в “кусок бизнес-логики в неожиданном месте”. В реальном проекте это ещё и проблема производительности и предсказуемости: validation может запускаться чаще, чем вы думаете. Поэтому держим правило: валидатор проверяет значение, а не состояние системы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ