← назад к разделу

Стандартных аннотаций — @NotNull, @Size, @Email — хватает для простых случаев. Когда логика сложнее, пишут свои ограничения. Разберём, как это сделать и как настроить понятные тексты ошибок.

Три типичных правила показывают всю развилку сразу: одно станет аннотацией на поле, второе — аннотацией на классе, а третьему в валидаторе вообще не место.

правилогде проверяетсяошибка привязана кформат телефона+7 и 11 цифр, одно поле@ValidPhone на полеаннотация + валидаторполю phoneаннотация на поле пароль = подтверждениедва поля сразу@PasswordsMatchна классе, TYPEобъекту, не полюaddPropertyNode логин уже занятнужен запрос в базупроверка в сервисене в валидатореответу сервисане Bean Validation итог: 3 правила — 2 в аннотациях, 1 в сервисе2 constraint-а: поле + класстексты по ключу из ValidationMessages1 проверка в сервисеправило с данными — не в валидаторе

Развилка проходит по двум вопросам: сколько полей нужно увидеть валидатору и нужны ли ему данные извне. Одно поле — аннотация на поле, два — аннотация на классе, запрос в базу — уже не валидация, а сервис.

Зачем нужны кастомные ограничения

Встроенные аннотации проверяют одно поле по простому критерию. Они не умеют:

  • сверять значение с базой («такой логин уже занят»);
  • проверять соответствие двух полей («пароль и подтверждение должны совпадать»);
  • применять бизнес-правило, специфичное для домена («скидка не может быть больше цены»).

Для этого создают кастомный constraint — пару из аннотации и класса-валидатора.

Как устроен кастомный constraint

Телефон должен начинаться с +7 и содержать одиннадцать цифр, и @Pattern это умеет, но регулярное выражение придётся копировать в каждый DTO с телефоном, а сообщение об ошибке в каждом писать заново. Своё ограничение убирает копирование: правило и сообщение живут в одном месте. Нужны два класса: аннотация и валидатор.

// 1. Аннотация
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER,
          ElementType.ANNOTATION_TYPE, ElementType.TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPhone {
    String message() default "Неверный формат телефона";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Три атрибута — message, groups, payload — обязательны для любого constraint, потому что их читает сама библиотека: message это текст ошибки, groups позволяет включать правило только в некоторых сценариях, а payload это место для служебных пометок вроде уровня серьёзности, которое в обычном коде остаётся пустым.

// 2. Валидатор
public class PhoneValidator implements ConstraintValidator<ValidPhone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true; // null проверяет @NotNull
        return value.matches("\\+7\\d{10}");
    }
}

Короткая формула: аннотация описывает контракт, валидатор — реализует его.

Применяется как обычная аннотация:

public record CreateUserRequest(
    @NotBlank String name,
    @ValidPhone String phone
) {}

Межполевая валидация

Проверить, что два поля согласованы между собой, нельзя на уровне отдельного поля — нужен constraint на уровне класса.

@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordsMatch {
    String message() default "Пароли не совпадают";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator
        implements ConstraintValidator<PasswordsMatch, ChangePasswordRequest> {

    @Override
    public boolean isValid(ChangePasswordRequest req, ConstraintValidatorContext ctx) {
        if (req.password() == null) return true;
        return req.password().equals(req.confirmPassword());
    }
}
@PasswordsMatch
public record ChangePasswordRequest(
    @NotBlank String password,
    @NotBlank String confirmPassword
) {}

При нарушении ошибка привязана к объекту, а не к полю. Чтобы привязать её к конкретному полю:

@Override
public boolean isValid(PasswordForm form, ConstraintValidatorContext ctx) {
    if (form.password().equals(form.confirmPassword())) {
        return true;
    }
    ctx.disableDefaultConstraintViolation();
    ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
       .addPropertyNode("confirmPassword")
       .addConstraintViolation();
    return false;
}

Ошибка привязана к полю confirmPassword, а не к форме целиком, поэтому в ответе она встанет рядом с нужным полем; false в конце и есть сигнал, что проверка не прошла.

Сообщения об ошибках

Текст ошибки задаётся в атрибуте message. Можно писать прямо в аннотации:

@ValidPhone(message = "Телефон должен начинаться с +7 и содержать 11 цифр")
String phone;

Или использовать ключ из файла сообщений в фигурных скобках:

String message() default "{validation.phone.invalid}";

Тогда текст берётся из ValidationMessages.properties — стандартного файла Bean Validation.

ключ в аннотации {validation.phone.invalid} message() отдаёт ключ файл сообщений ValidationMessages по ключу берётся строка локаль запроса её выбирает LocaleResolver по локали берётся файл перевод по языку ValidationMessages_en строка едет клиенту поле violations текст в теле ответа

Текст ошибки собирается из трёх мест: ключ стоит в аннотации, строка лежит в файле сообщений, а какой файл возьмут, решает локаль, определённая в приложении.

Тексты сообщений и языки

Создайте файл src/main/resources/ValidationMessages.properties — это стандартное место, откуда Bean Validation берёт тексты по ключу:

validation.phone.invalid=Неверный формат телефона
validation.size=Длина должна быть от {min} до {max} символов

Русский текст можно писать прямо так, в UTF-8: начиная с Java 9 файлы сообщений читаются как UTF-8, и перекодировать буквы в escape-последовательности, как делали раньше, не нужно. Переводы кладут рядом с суффиксом языка: ValidationMessages_en.properties.

В сообщение подставляются атрибуты самой аннотации — в фигурных скобках без доллара: {min}, {max}. Долларом обозначается другое, вычисляемое выражение, и чаще всего из него берут ${validatedValue} — значение, которое не прошло проверку.

Готовые тексты стандартных ограничений лежат под ключами вида {jakarta.validation.constraints.Size.message}; в старом коде на месте jakarta встретится javax — это до перехода на Spring Boot 3.

Отдельно стоит не путать этот файл с messages.properties, куда Spring складывает тексты всего приложения. Механизмы разные: первый читает валидатор, второй — Spring. Связать их можно, но по умолчанию они живут независимо.

Порядок: почему видно две ошибки сразу

Ограничение на уровне класса выполняется независимо от того, прошли ли полевые. Отсюда картина, которая выглядит как дефект: пользователь отправил форму с двумя пустыми паролями и получил три ошибки — «пароль обязателен», «подтверждение обязательно» и «пароли не совпадают». Последняя бессмысленна: сравнивать было нечего.

Причина в том, что валидатор по умолчанию проверяет всё сразу и не знает, что одна проверка зависит от другой. Лечится это последовательностью групп: полевые проверки объявляют первой группой, межполевые — второй, и вторая не выполняется, если первая дала нарушения.

@GroupSequence({ FieldChecks.class, CrossFieldChecks.class })
public interface RegistrationChecks {}

public record RegistrationRequest(
        @NotBlank(groups = FieldChecks.class) String password,
        @NotBlank(groups = FieldChecks.class) String confirmPassword) {}

@PasswordsMatch(groups = CrossFieldChecks.class)
public record …

// в контроллере проверяем по последовательности
public ResponseEntity<Void> register(@RequestBody @Validated(RegistrationChecks.class) RegistrationRequest r) { … }

Есть и более дешёвый приём, который закрывает большинство случаев: сделать сам межполевой валидатор устойчивым к пустоте — если одно из сравниваемых полей null, возвращать «проверка пройдена» и оставить работу полевым ограничениям. Тогда лишнее сообщение не появится, и группы не нужны.

@Override
public boolean isValid(RegistrationRequest value, ConstraintValidatorContext ctx) {
    if (value.password() == null || value.confirmPassword() == null) return true;   // не наша забота
    return value.password().equals(value.confirmPassword());
}

Правило: если проверок две-три — устойчивость к пустоте; если этапов много и порядок важен — последовательность групп.

Во что нарушение превращается в ответе

Статья про сообщения без ответа с сообщением неполна. Вот что получает клиент, когда сработали и полевое, и межполевое ограничение.

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Проверка не пройдена",
  "status": 400,
  "code": "VALIDATION_FAILED",
  "violations": [
    { "field": "email",    "message": "Неверный формат адреса", "rule": "Email" },
    { "field": "password", "message": "Пароли не совпадают",    "rule": "PasswordsMatch" }
  ]
}

Два момента, которые решают за вас удобство клиента. У межполевого нарушения тоже должно быть поле — иначе клиенту некуда подсветить ошибку; для этого в валидаторе явно указывают, к какому полю привязать сообщение:

ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate("{ru.shop.validation.PasswordsMatch.message}")
   .addPropertyNode("confirmPassword")
   .addConstraintViolation();

И имя правила (rule) полезно отдавать рядом с сообщением: клиент может по нему подобрать свой текст, не разбирая человеческую строку. Сборка такого ответа из исключения показана в статье про формат ошибок.

Как связать сообщения с общими ресурсами

Вопрос, который статья поднимает и не закрывает, решается одной настройкой: валидатору указывают тот же источник сообщений, что использует всё приложение.

@Configuration
public class ValidationConfig {

    @Bean
    LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource);     // тексты из messages*.properties
        return bean;
    }
}

После этого ключи из {…} в аннотациях ищутся в общих файлах сообщений, а отдельный файл сообщений валидации не нужен. Выгода не только в одном месте: общий источник умеет подстановку параметров, иерархию файлов и перезагрузку в разработке.

Какой язык выберется на самом деле

Здесь стык, о который спотыкаются: по умолчанию текст выбирается не по заголовку языка запроса, а по локали, которую определил определитель локали в приложении. В обычном Spring MVC он берёт её как раз из заголовка, и всё работает; но если в приложении стоит фиксированный определитель (частая настройка, чтобы сайт всегда был на одном языке), то заголовок игнорируется — и готовые файлы сообщений на другом языке не заработают.

Проверить это просто: отправить запрос с заголовком языка и посмотреть, меняется ли текст. Если нет, смотрят на определитель локали (LocaleResolver) и на то, не задан ли язык принудительно.

Отдельно: у самого валидатора язык берётся из контекста запроса, а вне запроса (обработка сообщения из очереди, фоновая задача) контекста нет — и текст будет на языке по умолчанию. Поэтому в машинных контурах на локализованные сообщения не опираются: там читают машинный код.

Практический вывод: локализация сообщений работает, когда совпадают три вещи — заголовок от клиента, определитель локали, который его читает, и наличие файла сообщений на этом языке. Отсутствие любой из трёх даёт молчаливый откат на язык по умолчанию.

Почему не ходить в базу из валидатора: цифры и статус

Причины «неочевидные зависимости и тестируемость» верны, но главная — другая, и она измеримая.

Время ответа. Валидатор вызывается на каждое поле при каждом запросе. Проверка «такой логин уже есть» — это запрос в базу; десять таких полей — десять запросов, и они последовательные. На практике это превращает время ответа с десятков миллисекунд в сотни: измеренная картина «было 40 мс, стало 900 мс на девяносто пятом процентиле» — типичный результат вынесения проверок уникальности в валидатор формы.

Гарантии всё равно нет. Между проверкой в валидаторе и вставкой в базу проходит время, и за него параллельный запрос успевает занять тот же логин. То есть проверка даёт красивое сообщение, но не защищает; защищает только ограничение в базе.

И статус другой. «Логин занят» — это не ошибка формы, а конфликт состояния: правильный ответ 409, а не 400. Валидатор физически не может отдать 409, потому что все его нарушения собираются в один ответ о неверном запросе. Значит, проверка уникальности живёт не в валидаторе, а в сценарии — и её результат превращается в 409 обработчиком, как разобрано в статье про уровни проверок.

Практический вывод: в валидаторе — только то, что проверяется по самому значению (формат, длина, диапазон, согласованность полей между собой). Всё, что требует состояния системы, — в сценарии, с ответом 409 и с ограничением в базе как гарантией.

Валидатор — это бин

Мелочь, которая экономит время: свой валидатор создаётся контейнером, поэтому зависимости в него передают через конструктор, как в любой бин.

@RequiredArgsConstructor
public class CouponCodeValidator implements ConstraintValidator<ValidCouponCode, String> {

    private final CouponFormatRules rules;          // обычная зависимость

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value == null || rules.matches(value);
    }
}

Внедрение через поле работает, но сегодня его не используют: конструктор делает зависимость обязательной и видимой, а валидатор — тестируемым без контейнера. И оговорка к предыдущему разделу: то, что зависимости технически доступны, не означает, что туда стоит передавать репозиторий.

Когда лучше проверить в сервисе

Кастомный constraint — правильный инструмент, если правило:

  • чисто синтаксическое (формат, диапазон, структура данных);
  • переиспользуется в нескольких местах;
  • не требует обращения к базе или внешнему сервису.

Если проверка требует запроса к базе данных («логин уже занят», «категория существует»), лучше вынести её в сервис. Инжектировать репозиторий в ConstraintValidator через @Autowired технически возможно, но это приводит к неочевидным зависимостям и усложняет тестирование. Правило: constraint — для структурной корректности, сервис — для бизнес-инвариантов с данными.

Коротко

  • Кастомный constraint — пара: аннотация с @Constraint и класс, реализующий ConstraintValidator<A, T>.
  • Три обязательных атрибута у любого constraint: message, groups, payload.
  • Межполевую валидацию делают constraint'ом на уровне класса (@Target(ElementType.TYPE)).
  • Тексты ошибок выносят в ValidationMessages.properties и ссылаются через {ключ}.
  • Проверки с обращением к БД или внешним сервисам — в сервисном слое, не в валидаторе.
  • Межполевое ограничение выполняется независимо от полевых: лишнее сообщение убирают устойчивостью валидатора к пустоте или последовательностью групп.
  • У межполевого нарушения тоже должно быть поле (привязывают явно), а рядом с сообщением полезно отдавать имя правила.
  • Сообщения валидации связывают с общими ресурсами через setValidationMessageSource; язык выбирается определителем локали, а не самим заголовком, и вне запроса откатывается на язык по умолчанию.
  • Из валидатора не ходят в базу: это последовательные запросы на каждое поле (и рост времени ответа в разы), гарантии всё равно нет, а «уже занято» — это 409, которого валидатор отдать не может.

Что почитать дальше