Опирается на правила:
R-VLD-CC-1…R-VLD-CC-5иR-VLD-CC-X1…R-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) возвращает false | R-VLD-CC-X1 | return value == null || ..., null обрабатывается @NotNull |
| Custom constraint inner-аннотацией в DTO | R-VLD-CC-X2 | Отдельные файлы в core/<bc>/validation/ или common/validation/ |
@AssertTrue isXxxValid()-метод внутри DTO | R-VLD-CC-X3 | Custom constraint, переиспользуемый |
Stateful validator (@Autowired с runtime-state, mutable fields) | R-VLD-CC-5 | Pure-функция от value; справочник как final-поле, загруженное в конструкторе |
Имя с префиксом Valid/Check/Is | R-VLD-CC-3 | @<DomainTerm>: @RussianPhone, @VatNumber |
| Custom constraint для формата на одном поле | — | Inline @Pattern(regexp = "...") |
Custom constraint вместо стандартного @Email | R-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.