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

Стандартных аннотаций — @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-* — правила для командных проектов.