Стандартных аннотаций — @NotNull, @Size, @Email — хватает для простых случаев. Когда логика сложнее, пишут свои ограничения. Разберём, как это сделать и как настроить понятные тексты ошибок.
Три типичных правила показывают всю развилку сразу: одно станет аннотацией на поле, второе — аннотацией на классе, а третьему в валидаторе вообще не место.
Развилка проходит по двум вопросам: сколько полей нужно увидеть валидатору и нужны ли ему данные извне. Одно поле — аннотация на поле, два — аннотация на классе, запрос в базу — уже не валидация, а сервис.
Зачем нужны кастомные ограничения
Встроенные аннотации проверяют одно поле по простому критерию. Они не умеют:
- сверять значение с базой («такой логин уже занят»);
- проверять соответствие двух полей («пароль и подтверждение должны совпадать»);
- применять бизнес-правило, специфичное для домена («скидка не может быть больше цены»).
Для этого создают кастомный 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.
Текст ошибки собирается из трёх мест: ключ стоит в аннотации, строка лежит в файле сообщений, а какой файл возьмут, решает локаль, определённая в приложении.
Тексты сообщений и языки
Создайте файл 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, которого валидатор отдать не может.
Что почитать дальше
- Bean Validation: @NotNull, @Size, @Valid и группы — стандартные аннотации и как запустить валидацию.
- Где валидировать: контроллер, сервис или домен — выбор правильного слоя.
- Ошибки REST API — как превратить
MethodArgumentNotValidExceptionв структурированный ответ. - Стандарты валидации R-VLD-* — правила для командных проектов.