Опирается на правила: R-VLD-CC-1R-VLD-CC-5 и R-VLD-CC-X1R-VLD-CC-X3 из Validation Style Guide → раздел 3. Custom constraints.

Важно знать

  • Custom constraint — это пара: annotation-interface (@RussianPhone) + ConstraintValidator-реализация (RussianPhoneValidator).
  • Пишем custom, только когда формат встречается на 3+ полях в проекте либо несёт доменный смысл, который ценно именовать (@VatNumber, @RussianPhone, @BicCode).
  • isValid(null, ctx) возвращает true. Null-проверка делается отдельной @NotNull/@NotBlank. Иначе ломается комбинация с null-аннотациями.
  • Validator stateless, без @Autowired-полей с runtime-state. Pure-функция от value.
  • Расположение: core/<bc>/validation/ для доменно-специфичных (@VatNumber), common/validation/ в отдельном модуле для общих технических (@RussianPhone, @UrlSafeBase64).
  • Имена — @<DomainTerm> без префиксов Valid/Check/Is. @RussianPhone — да; @ValidPhone, @CheckPhone, @IsPhone — нет.
  • Constraint в одном файле с DTO как inner-аннотация — антипаттерн (не переиспользуется, grep не находит).
  • @AssertTrue isValid()-метод внутри DTO — антипаттерн (не переиспользуется в другие DTO).

Custom constraint — это способ выразить доменное правило на уровне типа. Когда правило «телефон в формате +7XXXXXXXXXX» появляется на пятом поле, не нужно копировать regex — нужно завести @RussianPhone. Это тот же принцип, что и Value Object: повторяющееся правило вытаскивается в именованную абстракцию. Раскрытие раздела 3 гайда.

Когда вводим

Триггер — формат встречается часто либо имеет доменное имя. Признаки:

  • Тот же @Pattern(regexp = "...") появляется в 3+ местах.
  • Формат имеет общепринятое имя в проекте/индустрии (ИНН, BIC, телефон РФ, UUID v7).
  • Валидация сложнее regex (контрольная сумма ИНН, Luhn для карты).

Когда не нужен:

  • Формат уникальный для одного поля (@Pattern(regexp = "^CTR-\\d{4}-\\d{8}$") остаётся на месте).
  • Стандартная Jakarta-аннотация покрывает (@Email вместо @ValidEmail).
  • Правило выражается одной строкой и не несёт доменного смысла.

Структура: annotation + ConstraintValidator

R-VLD-CC-1: пара из двух файлов.

Файл 1 — annotation-interface:

// common/validation/RussianPhone.java
package ru.vikulinva.common.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RussianPhoneValidator.class)
public @interface RussianPhone {
    String message() default "Номер должен быть в формате +7XXXXXXXXXX";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Обязательные элементы:

  • @Target — где можно применить (FIELD + PARAMETER + RECORD_COMPONENT для records).
  • @Retention(RUNTIME) — иначе validator не увидит аннотацию.
  • @Constraint(validatedBy = ...) — связка с конкретным validator-классом.
  • message() — текст по умолчанию (на русском, см. Messages и i18n).
  • groups() и payload() — обязательные по спеке Jakarta, даже если не используем.

Файл 2 — ConstraintValidator-реализация:

// common/validation/RussianPhoneValidator.java
package ru.vikulinva.common.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

public class RussianPhoneValidator implements ConstraintValidator<RussianPhone, String> {

    private static final Pattern PHONE = Pattern.compile("^\\+7\\d{10}$");

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

Дальше использование тривиально:

public record CreateCustomerRequest(
    @NotBlank @RussianPhone String phone,
    @NotBlank @Email String email
) {}

isValid(null) возвращает true

R-VLD-CC-4, R-VLD-CC-X1: главное правило composability — custom constraint не проверяет null.

// ХОРОШО
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
    return value == null || PHONE.matcher(value).matches();
}

// ПЛОХО — false на null
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
    if (value == null) return false;
    return PHONE.matcher(value).matches();
}

Зачем такая логика:

  • Композиция с @NotNull / @NotBlank. Если поле обязательно, ставим @NotBlank @RussianPhone. @NotBlank ловит null/blank, @RussianPhone — формат.
  • Optional-поля. @RussianPhone String phone без @NotBlank означает «если есть — должен быть в формате; null допустим». Если бы validator падал на null, optional-поле было бы невозможно валидировать.
  • Чистая ответственность. Один constraint — одно правило. @RussianPhone отвечает только за формат; nullability — другой constraint.

Это стандартная конвенция Jakarta — то же поведение у встроенного @Email:

@Email String maybeEmail   // null допустим
@NotBlank @Email String email   // обязательный email

Validator — stateless

R-VLD-CC-5: ConstraintValidator — pure-функция от value, без полей с runtime-state.

// ХОРОШО
public class RussianPhoneValidator implements ConstraintValidator<RussianPhone, String> {
    private static final Pattern PHONE = Pattern.compile("^\\+7\\d{10}$");

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

// ПЛОХО — runtime state
public class RussianPhoneValidator implements ConstraintValidator<RussianPhone, String> {
    @Autowired private PhoneBlacklistService blacklist;   // ← state
    private int callCount = 0;                             // ← mutable state

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        callCount++;
        return value == null || !blacklist.isBlocked(value);
    }
}

Почему важно:

  • Thread-safety. Validator может вызываться из многих потоков одновременно. Мутирующее поле — race condition.
  • Тестируемость. new RussianPhoneValidator().isValid("+71234567890", null) — unit-тест без Spring-контекста.
  • Composability. Custom constraints без зависимостей переиспользуются в любом месте — на DTO, на configuration-properties, на доменных VO.

Если правило требует обращения к внешней системе (проверка ИНН в реестре ФНС) — это не валидация формата, это бизнес-проверка. Бизнес-проверки делаются в Handler/Service, не в Jakarta. См. Где валидировать — раздел про границу между «валидация контракта» и «бизнес-проверка».

Исключение — справочник, который грузится один раз при старте (например, список валидных кодов BIC). Тогда:

@Component
public class BicCodeValidator implements ConstraintValidator<BicCode, String> {
    private final Set<String> validBicCodes;

    public BicCodeValidator(BicCodeRegistry registry) {
        this.validBicCodes = Set.copyOf(registry.load());   // загружено один раз
    }
    // ...
}

Но обычно — pure regex/checksum, без DI.

Расположение и нэйминг

R-VLD-CC-2: расположение зависит от типа правила.

Доменно-специфичный — внутри BC:

core/order/validation/
  OrderNumber.java
  OrderNumberValidator.java
core/billing/validation/
  VatNumber.java
  VatNumberValidator.java

Такие constraint живут рядом с домен-классами, потому что они — часть domain vocabulary. @VatNumber понятен только в контексте core/billing/.

Общий технический — отдельный модуль:

common/validation/
  RussianPhone.java
  RussianPhoneValidator.java
  UrlSafeBase64.java
  UrlSafeBase64Validator.java
  Iso8601Duration.java
  Iso8601DurationValidator.java

@RussianPhone нейтрален к домену — переиспользуется и в Customer-BC, и в Order-BC, и в Notification-BC. Логично в common/.

R-VLD-CC-3: имена без префиксов.

// ХОРОШО
@RussianPhone
@VatNumber
@BicCode
@Iso8601Duration

// ПЛОХО
@ValidPhone     // что значит "valid"? в каком смысле?
@CheckVat       // глагол — это команда, а аннотация — это утверждение
@IsBicCode      // "Is" префикс для boolean-методов, не для типов
@PhoneValid

Имя — это существительное, описывающее, чем должно быть значение. @RussianPhone phone читается как «phone — это российский телефонный номер». Это естественное чтение.

Constraint в файле с DTO — нет

R-VLD-CC-X2, R-VLD-CC-X3: соблазн положить custom constraint рядом с DTO, который его использует.

// ПЛОХО — inner-аннотация
public record CreateCustomerRequest(
    @CreateCustomerRequest.PhoneFormat String phone
) {

    @Target(FIELD)
    @Retention(RUNTIME)
    @Constraint(validatedBy = PhoneFormatValidator.class)
    public @interface PhoneFormat {
        String message() default "Wrong phone";
        // ...
    }

    public static class PhoneFormatValidator implements ConstraintValidator<PhoneFormat, String> {
        // ...
    }
}

Что не так:

  • Не переиспользуется. Другой DTO с тем же правилом — копипаст inner-классов.
  • git grep RussianPhone не находит — аннотация спрятана под именем CreateCustomerRequest.PhoneFormat.
  • Связанность по класс-уровню. Изменение в CreateCustomerRequest затрагивает и validator-код — лишний blast radius.

То же касается @AssertTrue-метода:

// ПЛОХО — правило живёт в одном DTO
public record CreateCustomerRequest(String phone) {
    @AssertTrue(message = "Phone must be Russian")
    public boolean isPhoneValid() {
        return phone == null || phone.matches("^\\+7\\d{10}$");
    }
}

Тот же regex в CreateEmployeeRequest придётся написать заново. Не переиспользуется, не находится grep-ом по имени правила.

Правильно — отдельные файлы в common/validation/, @RussianPhone применяется в любом месте.

Что запрещено

АнтипаттернПравилоЧто взамен
isValid(null, ctx) возвращает falseR-VLD-CC-X1return value == null || ..., null обрабатывается @NotNull
Custom constraint inner-аннотацией в DTOR-VLD-CC-X2Отдельные файлы в core/<bc>/validation/ или common/validation/
@AssertTrue isXxxValid()-метод внутри DTOR-VLD-CC-X3Custom constraint, переиспользуемый
Stateful validator (@Autowired с runtime-state, mutable fields)R-VLD-CC-5Pure-функция от value; справочник как final-поле, загруженное в конструкторе
Имя с префиксом Valid/Check/IsR-VLD-CC-3@<DomainTerm>: @RussianPhone, @VatNumber
Custom constraint для формата на одном полеInline @Pattern(regexp = "...")
Custom constraint вместо стандартного @EmailR-VLD-STD-X2@Email

Куда дальше

  • Validation → раздел 3. Custom constraints — нормативные формулировки R-VLD-CC-*.
  • Стандартные constraints — что есть из коробки, до custom.
  • Cross-field validation — class-level constraint для правил между полями.
  • Messages и i18n — как писать message() и где локализация.
  • OpenAPI-сгенерированные DTO — custom constraint в wrapper-class, когда DTO generated.