Опирается на правила:
R-VLD-XF-1…R-VLD-XF-2иR-VLD-XF-X1…R-VLD-XF-X2из Validation Style Guide → раздел 5. Cross-field validation.
Важно знать
- Cross-field = правило, в котором участвуют 2+ поля одного объекта:
dateFrom ≤ dateTo,password == passwordConfirm,amount <= creditLimit.- Реализуется как class-level constraint:
@Target(TYPE)на интерфейсе,ConstraintValidator<MyConstraint, MyDto>на сам класс DTO.- Прицеп ошибки к конкретному полю — через
ctx.buildConstraintViolationWithTemplate(...).addPropertyNode("dateFrom").addConstraintViolation(). Без этого ошибка прицепляется к объекту целиком — пользователю неудобно.- Имя — описывает правило, не объект:
@DateRange,@PasswordsMatch,@AmountWithinLimit. Не@OrderRequestValid, не@FilterValid.@AssertTrue-метод внутри DTO — антипаттерн. Не переиспользуется в другие DTO с тем же правилом, теряется при рефакторинге.- Cross-field валидация в Handler перед
dispatcher.dispatch(...)— антипаттерн. Это контракт DTO, а не бизнес-правило.
Cross-field — узкая категория валидаций, в которой стандартный Jakarta не помогает: проверка взаимоотношения между полями. Класс-level constraint с собственным validator-ом — единственный способ держать правило переиспользуемым и с понятным сообщением для пользователя. Раскрытие раздела 5 гайда.
Класс-level constraint: структура
R-VLD-XF-1: pair annotation + validator, аналогично custom constraints, но @Target(TYPE).
Файл 1 — annotation:
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface DateRange {
String message() default "dateFrom должен быть не позже dateTo";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Отличие от обычного custom constraint — @Target(TYPE), не FIELD. Это значит «применяется к классу, не к полю».
Файл 2 — validator:
public class DateRangeValidator implements ConstraintValidator<DateRange, OrderFilterRequest> {
@Override
public boolean isValid(OrderFilterRequest req, ConstraintValidatorContext ctx) {
if (req.dateFrom() == null || req.dateTo() == null) {
return true; // null-проверка — на отдельных @NotNull, не здесь
}
if (!req.dateFrom().isAfter(req.dateTo())) {
return true;
}
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("dateFrom")
.addConstraintViolation();
return false;
}
}
Тонкости:
- Тип validator-а — конкретный DTO (
ConstraintValidator<DateRange, OrderFilterRequest>). Это означает, что@DateRangeприменим только кOrderFilterRequest. Если правило нужно на нескольких DTO — нужны разные validator-ы (см. ниже). - Null-tolerant. Возвращаем
trueесли хоть одно поле null. Null-проверка делается отдельными@NotNull— composability та же, что и в custom constraints. addPropertyNode("dateFrom")— прицепляет ошибку к конкретному полю. Без этого ошибка появится с пустымfield-ом вviolations, и фронту негде показывать стрелку.
Применение:
@DateRange
public record OrderFilterRequest(
@PastOrPresent LocalDate dateFrom,
@PastOrPresent LocalDate dateTo,
@Size(max = 100) String customerName
) {}
@Valid на контроллере прогонит и field-level, и class-level constraints автоматически.
Прицеп ошибки к полю
R-VLD-XF-1 подразумевает: addPropertyNode(...) обязателен для понятного UX.
Без addPropertyNode:
{
"type": "/errors/validation-error",
"title": "Validation failed",
"status": 400,
"violations": [
{"field": "", "message": "dateFrom должен быть не позже dateTo"}
]
}
Поле пустое, фронт не знает, где показать ошибку. С addPropertyNode("dateFrom"):
{
"violations": [
{"field": "dateFrom", "message": "dateFrom должен быть не позже dateTo"}
]
}
Поле понятное, UI рисует красную рамку около dateFrom. Если правило про несколько полей одновременно — прицепляем к каждому:
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("dateFrom").addConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("dateTo").addConstraintViolation();
return false;
Два violations в ответе, оба указывают на свои поля.
Имя описывает правило
R-VLD-XF-2: имя — это что проверяется, не где.
// ХОРОШО
@DateRange
@PasswordsMatch
@AmountWithinLimit
@StartBeforeEnd
// ПЛОХО
@OrderRequestValid // что валидируется?
@FilterValid // в каком смысле?
@CustomerRequestCheck // и опять — что именно?
Правильное имя имеет два следствия:
- Переиспользуемо.
@DateRangeпонятен вOrderFilterRequest,BillingPeriodRequest,ContractValidityRequest. Если правило применяется на 2+ DTO — пишем generic-validator (см. ниже). - Discoverable.
git grep @DateRangeнаходит все места применения за секунду.
Generic-вариант на несколько DTO:
public interface HasDateRange {
LocalDate dateFrom();
LocalDate dateTo();
}
public class DateRangeValidator implements ConstraintValidator<DateRange, HasDateRange> {
@Override
public boolean isValid(HasDateRange value, ConstraintValidatorContext ctx) { /* ... */ }
}
@DateRange
public record OrderFilterRequest(LocalDate dateFrom, LocalDate dateTo, ...) implements HasDateRange {}
@DateRange
public record BillingPeriodRequest(LocalDate dateFrom, LocalDate dateTo, ...) implements HasDateRange {}
DTO реализует marker-interface, validator работает с marker-интерфейсом — одна реализация на любые DTO с парой dateFrom/dateTo.
@AssertTrue в DTO — антипаттерн
R-VLD-XF-X1: соблазн положить cross-field правило методом прямо в DTO.
// ПЛОХО — правило живёт в одном DTO
public record OrderFilterRequest(LocalDate dateFrom, LocalDate dateTo) {
@AssertTrue(message = "dateFrom должен быть не позже dateTo")
public boolean isDateRangeValid() {
if (dateFrom == null || dateTo == null) return true;
return !dateFrom.isAfter(dateTo);
}
}
Что не так:
- Не переиспользуется. В
BillingPeriodRequestпридётся скопировать тот же метод. Семантика одна, код продублирован. - Не находится grep-ом по правилу.
git grep @DateRangeнайдёт.git grep isDateRangeValidнайдёт только если кто-то назвал метод одинаково — а имя метода — частное решение в каждом DTO. - Прицеп к полю невозможен.
@AssertTrueприцепит ошибку к методу (isDateRangeValid), не к полю. Вviolationsбудет странныйfield: "dateRangeValid".
Правильно — class-level @DateRange с явным addPropertyNode("dateFrom"). Переиспользование, явное имя, понятный UX.
Cross-field в Handler — нет
R-VLD-XF-X2: соблазн — проверить cross-field правило в Handler, потому что «там удобно».
// ПЛОХО — cross-field в Handler
@Component
class GetOrdersHandler implements UseCaseHandler<GetOrders, List<Order>> {
public List<Order> handle(GetOrders query) {
if (query.dateFrom() != null && query.dateTo() != null
&& query.dateFrom().isAfter(query.dateTo())) {
throw new IllegalArgumentException("dateFrom должен быть не позже dateTo");
}
// ...
}
}
Почему плохо:
- Это контракт DTO, не бизнес-правило. Правило «dateFrom ≤ dateTo» не зависит от загрузки агрегатов, не зависит от состояния системы — оно про входные данные. Контракт проверяется на edge, не в Handler.
IllegalArgumentException→ 500. Без явного маппинга в edge-handlerIllegalArgumentExceptionинтерпретируется как технический. Клиент получит 500 вместо 400, что неправильно по семантике.- Дублирование с возможным class-level constraint. Завтра кто-то добавит
@DateRangeна DTO — проверка в Handler станет двойной.
Правило «входной DTO» (структура, формат, отношения между полями) — Jakarta на DTO. Правило «состояние домена» (нельзя отменить отгруженный заказ) — domain exception в методе агрегата. См. Где валидировать.
Примеры распространённых cross-field правил
| Правило | Аннотация | Куда прицепляем |
|---|---|---|
dateFrom ≤ dateTo | @DateRange | dateFrom |
password == passwordConfirm | @PasswordsMatch | passwordConfirm |
amount ≤ creditLimit | @AmountWithinLimit | amount |
startTime < endTime | @StartBeforeEnd | endTime |
Один из email/phone обязателен | @AtLeastOneContact | оба или email |
validUntil > validFrom + minDays | @MinValidityPeriod | validUntil |
Каждое правило — отдельный constraint с описательным именем. Их в проекте обычно 3-7 штук — это не «бесконечный список validator-ов», а конечный набор частых сценариев.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@AssertTrue isXxxValid()-метод в DTO | R-VLD-XF-X1 | Class-level @<Rule> constraint + ConstraintValidator |
Cross-field валидация в Handler перед dispatcher.dispatch | R-VLD-XF-X2 | Class-level constraint на DTO |
Без addPropertyNode — ошибка без поля | R-VLD-XF-1 | ctx.buildConstraintViolationWithTemplate(...).addPropertyNode("...").addConstraintViolation() |
Имя @<Dto>Valid (@OrderRequestValid) | R-VLD-XF-2 | Имя по правилу: @DateRange, @PasswordsMatch |
| Cross-field правило, дублированное в 5 DTO inline | — | HasDateRange marker-interface + один validator |
Куда дальше
- Validation → раздел 5. Cross-field validation — нормативные формулировки
R-VLD-XF-*. - Custom constraints — field-level constraint, который проще и встречается чаще.
- Где валидировать — почему cross-field на DTO, не в Handler.
- Validation groups — другой механизм, который часто путают с cross-field (но решает другую задачу).
- REST API → ProblemDetails — как
violationsвыглядят в ответе.