Опирается на правила: R-VLD-XF-1R-VLD-XF-2 и R-VLD-XF-X1R-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-handler IllegalArgumentException интерпретируется как технический. Клиент получит 500 вместо 400, что неправильно по семантике.
  • Дублирование с возможным class-level constraint. Завтра кто-то добавит @DateRange на DTO — проверка в Handler станет двойной.

Правило «входной DTO» (структура, формат, отношения между полями) — Jakarta на DTO. Правило «состояние домена» (нельзя отменить отгруженный заказ) — domain exception в методе агрегата. См. Где валидировать.

Примеры распространённых cross-field правил

ПравилоАннотацияКуда прицепляем
dateFrom ≤ dateTo@DateRangedateFrom
password == passwordConfirm@PasswordsMatchpasswordConfirm
amount ≤ creditLimit@AmountWithinLimitamount
startTime < endTime@StartBeforeEndendTime
Один из email/phone обязателен@AtLeastOneContactоба или email
validUntil > validFrom + minDays@MinValidityPeriodvalidUntil

Каждое правило — отдельный constraint с описательным именем. Их в проекте обычно 3-7 штук — это не «бесконечный список validator-ов», а конечный набор частых сценариев.

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

АнтипаттернПравилоЧто взамен
@AssertTrue isXxxValid()-метод в DTOR-VLD-XF-X1Class-level @<Rule> constraint + ConstraintValidator
Cross-field валидация в Handler перед dispatcher.dispatchR-VLD-XF-X2Class-level constraint на DTO
Без addPropertyNode — ошибка без поляR-VLD-XF-1ctx.buildConstraintViolationWithTemplate(...).addPropertyNode("...").addConstraintViolation()
Имя @<Dto>Valid (@OrderRequestValid)R-VLD-XF-2Имя по правилу: @DateRange, @PasswordsMatch
Cross-field правило, дублированное в 5 DTO inlineHasDateRange 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 выглядят в ответе.