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. Состав кастомного 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, вы неявно делаете поле обязательным. Иногда это действительно нужно, но чаще это просто случайная ошибка. Хорошее правило для старта: nulltrue, а обязательность задаём явно отдельной аннотацией. Это делает DTO гораздо более читаемым: у поля сразу видно, обязательно оно или нет.

Ошибка №5: валидатор начинает ходить в сервис/репозиторий.
Это прямой путь к смешению слоёв. Как только валидатор вызывает сервис, он перестаёт быть input validation и превращается в “кусок бизнес-логики в неожиданном месте”. В реальном проекте это ещё и проблема производительности и предсказуемости: validation может запускаться чаще, чем вы думаете. Поэтому держим правило: валидатор проверяет значение, а не состояние системы.

1
Задача
Spring REST & MVC, 17 уровень, 1 лекция
Недоступна
Custom constraint для уникальности тегов без учёта регистра
Custom constraint для уникальности тегов без учёта регистра
1
Задача
Spring REST & MVC, 17 уровень, 1 лекция
Недоступна
Custom constraint для строки без крайних пробелов
Custom constraint для строки без крайних пробелов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ