Стандартных аннотаций — @NotNull, @Size, @Email — хватает для простых случаев. Когда логика сложнее, пишут свои ограничения. Разберём, как это сделать и как настроить понятные тексты ошибок.
Зачем нужны кастомные ограничения
Встроенные аннотации проверяют одно поле по простому критерию. Они не умеют:
- сверять значение с базой («такой логин уже занят»);
- проверять соответствие двух полей («пароль и подтверждение должны совпадать»);
- применять бизнес-правило, специфичное для домена («скидка не может быть больше цены»).
Для этого создают кастомный constraint — пару из аннотации и класса-валидатора.
Как устроен кастомный constraint
Нужны два класса: аннотация и валидатор.
// 1. Аннотация
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPhone {
String message() default "Неверный формат телефона";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Три атрибута — message, groups, payload — обязательны для любого constraint.
// 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
) {}
При нарушении ошибка привязана к объекту, а не к полю. Чтобы привязать её к конкретному полю:
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("confirmPassword")
.addConstraintViolation();
return false;
Сообщения об ошибках
Текст ошибки задаётся в атрибуте message. Можно писать прямо в аннотации:
@ValidPhone(message = "Телефон должен начинаться с +7 и содержать 11 цифр")
String phone;
Или использовать ключ из файла сообщений в фигурных скобках:
String message() default "{validation.phone.invalid}";
Тогда текст берётся из ValidationMessages.properties — стандартного файла Bean Validation.
Интернационализация через messages.properties
Создайте файл src/main/resources/ValidationMessages.properties:
validation.phone.invalid=Неверный формат телефона
validation.passwords.mismatch=Пароли не совпадают
Или в UTF-8 напрямую — Spring Boot 3 поддерживает это без дополнительной настройки:
validation.phone.invalid=Неверный формат телефона
validation.passwords.mismatch=Пароли не совпадают
В сообщение можно подставить атрибуты аннотации через ${атрибут} или значение через {javax.validation.constraints.Size.message}. Для большинства случаев достаточно фиксированного текста с ключом.
Когда лучше проверить в сервисе
Кастомный constraint — правильный инструмент, если правило:
- чисто синтаксическое (формат, диапазон, структура данных);
- переиспользуется в нескольких местах;
- не требует обращения к базе или внешнему сервису.
Если проверка требует запроса к базе данных («логин уже занят», «категория существует»), лучше вынести её в сервис. Инжектировать репозиторий в ConstraintValidator через @Autowired технически возможно, но это приводит к неочевидным зависимостям и усложняет тестирование. Правило: constraint — для структурной корректности, сервис — для бизнес-инвариантов с данными.
Коротко
- Кастомный constraint — пара: аннотация с
@Constraintи класс, реализующийConstraintValidator<A, T>. - Три обязательных атрибута у любого constraint:
message,groups,payload. - Межполевую валидацию делают constraint'ом на уровне класса (
@Target(ElementType.TYPE)). - Тексты ошибок выносят в
ValidationMessages.propertiesи ссылаются через{ключ}. - Проверки с обращением к БД или внешним сервисам — в сервисном слое, не в валидаторе.
Что почитать дальше
- Bean Validation: @NotNull, @Size, @Valid и группы — стандартные аннотации и как запустить валидацию.
- Где валидировать: контроллер, сервис или домен — выбор правильного слоя.
- Ошибки REST API — как превратить
MethodArgumentNotValidExceptionв структурированный ответ. - Стандарты валидации R-VLD-* — правила для командных проектов.