JavaRush /Курсы /Spring REST & MVC /Cross-field validation для DTO

Cross-field validation для DTO

Spring REST & MVC
17 уровень , 2 лекция
Открыта

1. Cross-field validation: когда поля спорят

Представьте, что вы проверяете входной запрос как охранник на входе в клуб: паспорт посмотрел, одежду оценил, по карманам похлопал — всё вроде ок. Но иногда проблема не в “паспорт поддельный”, а в том, что человек пришёл одновременно “в спортивках” и “на строгий дресс‑код”, то есть комбинация условий противоречивая. Вот это и есть cross-field validation: поля по отдельности нормальные, но вместе — нет.

Классический пример из Task Tracker API — поиск задач по диапазону дат. Поля dueAfter и dueBefore по отдельности могут быть корректными датами, но если dueAfter позже dueBefore, запрос превращается в логическую загадку. Ещё пример — фильтры, которые взаимно исключают друг друга: клиент просит “только архивные” и одновременно status=IN_PROGRESS. Это не “невалидный enum”, это конфликт смысла входного набора параметров.

И вот тут очень хочется написать if прямо в контроллере или сервисе. Но если вы так сделаете в трёх местах, то на четвёртом начнёте копировать, на пятом — забывать, а на шестом — тихо ненавидеть себя и весь мир. Cross-field validation как раз и нужна, чтобы такие правила жили в одном понятном месте и были частью входного контракта, а не “случайным условием где-то внизу”.

Чтобы закрепить идею, удобно держать в голове такую мини-схему:

flowchart TD
  A[Пришёл запрос] --> B[Binding параметров / body в DTO]
  B --> C[Bean Validation]
  C -->|ok| D[Контроллер вызывает сервис]
  C -->|fail| E[Запрос не проходит дальше]

Мы сегодня работаем ровно в точке C, но на уровне всего DTO, а не на уровне отдельного поля.

2. Field-level vs class-level: где живёт правило

Когда человек только начинает работать с Bean Validation, возникает очень естественная иллюзия: “ну у меня же есть аннотации на полях, значит любая проверка — это аннотация на поле”. На практике это как пытаться починить автомобиль, меняя только дворники: иногда помогает (дождь закончился), но двигатель всё равно не заведётся. Cross-field правила требуют видеть сразу несколько значений, и это меняет место, где живёт проверка.

Давайте сравним два подхода не как “список аннотаций”, а как инженерный выбор.

Что проверяем Где ставим аннотацию Что видит ConstraintValidator Типовой пример
Одно поле само по себе на поле (ElementType.FIELD) значение поля title не пустой
Коллекция как целое на поле-коллекцию весь список теги уникальны
Согласованность нескольких полей на тип (ElementType.TYPE) весь объект dueAfter <= dueBefore
“Хотя бы одно поле” на тип (ElementType.TYPE) весь объект PATCH без полей запрещён

Важно: class-level validation не делает правило “более умным”, она делает его правильно размещённым. Поле‑за‑полем вы просто физически не сможете выразить правило “эти два поля не должны противоречить друг другу” — валидатор не знает, что происходит “по соседству”.

3. Class-level constraint: аннотация и валидатор

Class-level constraint технически выглядит почти так же, как field-level: у нас всё равно есть аннотация и валидатор. Но отличие принципиальное: аннотацию мы ставим на класс/record, а валидатор получает весь DTO целиком. И дальше он решает, согласованы ли значения внутри объекта.

Скелет такой аннотации очень похож на то, что вы делали в прошлой лекции, только @Target будет ElementType.TYPE. Давайте покажу минимальный “каркас”, на который мы потом будем навешивать конкретные правила.

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

// Аннотация будет применяться к типу (классу/record), а не к конкретному полю.
@Target(TYPE)
@Retention(RUNTIME)
// Bean Validation поймёт, что это constraint-аннотация.
@Constraint(validatedBy = {}) // Валидаторов нет — это просто "каркас" для примера.
public @interface DemoTypeConstraint {
    // Сообщение по умолчанию, если валидатор вернёт false.
    String message() default "DTO is inconsistent";

    // Стандартные поля Bean Validation (нужны, чтобы аннотация считалась полноценным constraint).
    Class
  [] groups() default {};
    Class
  [] payload() default {};
}

Да, это выглядит как бюрократия, но у неё есть смысл. Поля message/groups/payload — стандарт Bean Validation: без них аннотация не считается constraint-аннотацией “по правилам игры”. А вот validatedBy — это мост к конкретному валидатору, который будет выполнять проверку.

Когда вы пишете валидатор, ключевой момент — generic-параметры. Для class-level это будет примерно так: ConstraintValidator<Аннотация, ВашDTO>. То есть во втором параметре — тип DTO, который вы проверяете целиком.

4. Диапазон дат в TaskSearchCriteria

Диапазон дат — одна из самых “честных” причин для cross-field validation. Пока у нас есть только dueAfter или только dueBefore, запрос всё ещё имеет смысл: “все задачи после даты” или “все задачи до даты”. Но когда указаны обе даты, мы внезапно обязаны проверить, что это именно диапазон, а не временная петля.

В Task Tracker API такой пример особенно полезен, потому что TaskSearchCriteria — это не request body, а criteria-объект для query-параметров. И нам важно научиться валидировать не только JSON в @RequestBody, но и такие “собранные” DTO, которые приходят из query string через @ModelAttribute.

Criteria-DTO для class-level проверки

Сделаем небольшой, учебно удобный TaskSearchCriteria. В реальном проекте полей будет больше, но для понимания нам важны только даты.

import java.time.LocalDate;

// Class-level constraint: проверяем согласованность значений внутри DTO.
@ValidTaskDueDateRange
public record TaskSearchCriteria(
        LocalDate dueAfter,
        LocalDate dueBefore
) {}

Обратите внимание: аннотация стоит над record, то есть на типе. Это и есть class-level.

Аннотация @ValidTaskDueDateRange

Теперь описываем constraint-аннотацию. Название старайтесь делать предметным: не @DateRangeValidator3000, а так, чтобы по DTO было ясно, что проверяем.

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

// Ставим на тип: правило зависит от двух полей.
@Target(TYPE)
@Retention(RUNTIME)
// Здесь явно связываем аннотацию с конкретным валидатором.
@Constraint(validatedBy = ValidTaskDueDateRangeValidator.class)
public @interface ValidTaskDueDateRange {
    // Текст ошибки по умолчанию (если не переопределён в месте использования).
    String message() default "dueAfter must not be later than dueBefore";

    Class
  [] groups() default {};
    Class
  [] payload() default {};
}

Здесь всё почти как в предыдущей лекции, но важна одна строка: validatedBy = ValidTaskDueDateRangeValidator.class. Это означает: “когда увидишь эту аннотацию на объекте, проверь объект вот этим валидатором”.

Валидатор ValidTaskDueDateRangeValidator

Валидатор получает весь TaskSearchCriteria. И дальше — простая логика: если обе даты присутствуют, проверяем порядок. Если одна или обе отсутствуют — правило диапазона не нарушено.

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidTaskDueDateRangeValidator
        implements ConstraintValidator<ValidTaskDueDateRange, TaskSearchCriteria> {

    @Override
    public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
        // По соглашению Bean Validation: null обычно "валиден" для конкретного правила.
        if (value == null || value.dueAfter() == null || value.dueBefore() == null) {
            return true;
        }

        // Основное правило: dueAfter не должен быть позже dueBefore.
        return !value.dueAfter().isAfter(value.dueBefore());
    }
}

Здесь специально нет никакой “магии”: мы не ходим в сервис, не читаем базу, не делаем сетевые вызовы. Мы отвечаем на один вопрос: согласованы ли два поля внутри входного DTO.

И очень важная (для начинающих) мысль: возврат true на null — это не “мы забыли проверить null”. Это осознанная практика Bean Validation. Обычно обязательность поля (если она нужна) проверяется отдельно стандартными аннотациями или отдельным правилом. А этот валидатор отвечает только за порядок дат.

5. Взаимоисключающие фильтры в TaskSearchCriteria

Теперь ситуация чуть интереснее, потому что здесь не “математика дат”, а “согласованность смысла”. В нашем проекте есть статус ARCHIVED, и при этом в запросе могут быть фильтры status и archived. По отдельности оба параметра нормальны: status=TODO или archived=true. Но вместе они иногда превращаются в противоречие: “покажи архивные, но статус у них IN_PROGRESS”.

В реальной жизни такие конфликты встречаются постоянно: фильтр “активные” плюс статус “закрыто”, диапазон minPrice/maxPrice, “sortBy” без “sortDirection” (если вы требуете), “либо поле A, либо поле B” — всё это один и тот же класс задач: входные данные сами по себе допустимы, но комбинация — нет.

Обновим TaskSearchCriteria: status и archived

Чтобы валидатор мог сравнивать поля, они должны быть в DTO. Сделаем минимальную версию criteria с четырьмя полями и повесим две class-level проверки сразу.

import com.example.tasktracker.domain.model.TaskStatus;

import java.time.LocalDate;

// На одном DTO может быть несколько class-level правил: каждое отвечает за своё.
@ValidTaskDueDateRange
@ConsistentTaskFilters
public record TaskSearchCriteria(
        TaskStatus status,
        Boolean archived,
        LocalDate dueAfter,
        LocalDate dueBefore
) {}

Да, на одном типе может быть несколько class-level аннотаций. Это нормально, если каждая отвечает за свой кусок смысла.

Аннотация @ConsistentTaskFilters

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

// Правило тоже class-level: оно сравнивает archived и status.
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = ConsistentTaskFiltersValidator.class)
public @interface ConsistentTaskFilters {
    // Сообщение по умолчанию: конфликт между фильтрами.
    String message() default "status filter conflicts with archived filter";

    Class
  [] groups() default {};
    Class
  [] payload() default {};
}

Валидатор: логика “не противоречь сам себе”

Сформулируем правило очень прикладно: если archived=true, то status может быть только ARCHIVED (или вообще не задан). Если archived=false, то status не должен быть ARCHIVED. Если archived не задан, правило не применяется.

import com.example.tasktracker.domain.model.TaskStatus;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ConsistentTaskFiltersValidator
        implements ConstraintValidator<ConsistentTaskFilters, TaskSearchCriteria> {

    @Override
    public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
        // Если какого-то из значений нет — конфликт смысла не проверяем.
        if (value == null || value.archived() == null || value.status() == null) {
            return true;
        }

        // archived=true допускает только status=ARCHIVED.
        if (value.archived() && value.status() != TaskStatus.ARCHIVED) {
            return false;
        }

        // archived=false не допускает status=ARCHIVED.
        return !(!value.archived() && value.status() == TaskStatus.ARCHIVED);
    }
}

Код короткий, но смысл важный: мы не пытаемся угадать “что хотел клиент”. Мы просто честно говорим: “ваш набор фильтров логически не складывается”. Это и есть input validation на уровне согласованности DTO.

6. PATCH: хотя бы одно поле в TaskPatchRequest

Patch-like DTO — это место, где кросс-полевая валидация внезапно становится почти обязательной. Почему? Потому что в PATCH вы сознательно позволяете клиенту прислать неполный набор полей. И это хорошо, пока клиент прислал хоть что-то. Но иногда он присылает пустой объект {} и ожидает, что сервер “сам догадается, что менять”. Сервер, конечно, может догадаться, но обычно только до первого продакшн-инцидента.

Поэтому часто вводят правило: patch-запрос должен содержать хотя бы одно поле для изменения. Это не бизнес-валидация, потому что нам не нужно текущее состояние задачи. Это чистый вопрос “запрос вообще что-то пытается изменить или просто пришёл поговорить?”.

DTO TaskPatchRequest

Сделаем компактную версию: несколько полей и одно class-level правило.

import jakarta.validation.constraints.Size;

import java.time.LocalDate;

// Class-level правило: PATCH не должен быть "пустым" (без полей для изменения).
@AtLeastOneFieldPresent
public record TaskPatchRequest(
        // Field-level ограничения: формат каждого поля по отдельности.
        @Size(min = 3, max = 120) String title,
        @Size(max = 2000) String description,
        LocalDate dueDate
) {}

Аннотация @AtLeastOneFieldPresent

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

// Проверка относится ко всему DTO: нужно увидеть сразу все поля.
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = AtLeastOneFieldPresentValidator.class)
public @interface AtLeastOneFieldPresent {
    // Сообщение по умолчанию: клиент не передал ни одного поля для изменения.
    String message() default "at least one field must be provided";

    Class
  [] groups() default {};
    Class
  [] payload() default {};
}

Валидатор: “хоть одно поле не null”

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class AtLeastOneFieldPresentValidator
        implements ConstraintValidator<AtLeastOneFieldPresent, TaskPatchRequest> {

    @Override
    public boolean isValid(TaskPatchRequest value, ConstraintValidatorContext context) {
        // null как объект обычно трактуем как "валидно" для такого правила.
        if (value == null) {
            return true;
        }

        // Минимальная логика PATCH: должно быть хотя бы одно значение для изменения.
        return value.title() != null || value.description() != null || value.dueDate() != null;
    }
}

Да, это простой “логический OR”. И это нормально. Валидация часто должна быть скучной. Чем скучнее валидатор, тем меньше у него шансов внезапно “порадовать” вас в пятницу вечером.

7. Ошибки по полям и запуск в Spring MVC

Пока мы возвращали просто false, и Bean Validation формально знает: “объект невалиден”, а сообщение — из message(). Но когда правило class-level, возникает практическая проблема: к чему привязать ошибку? Если ошибку приклеить к “объекту”, потом клиенту (или фронтенду) труднее понять, какое поле подсветить.

Для этого существует ConstraintValidatorContext. Он позволяет отключить дефолтную ошибку и создать новую — прикреплённую к конкретному property path. Это особенно полезно в cross-field правилах вроде диапазона дат: вы хотите, чтобы ошибка выглядела как “проблема в dueAfter/dueBefore”, а не как “весь объект плохой”.

ConstraintValidatorContext: привязка к полю

Покажу тот же валидатор диапазона дат, но с привязкой ошибки к полю dueAfter. Мы не будем усложнять: одно нарушение — одно поле.

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidTaskDueDateRangeValidator
        implements ConstraintValidator<ValidTaskDueDateRange, TaskSearchCriteria> {

    @Override
    public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
        // Если не хватает данных для сравнения — диапазон "не нарушен".
        if (value == null || value.dueAfter() == null || value.dueBefore() == null) {
            return true;
        }

        // Если порядок корректный — всё ок.
        if (!value.dueAfter().isAfter(value.dueBefore())) {
            return true;
        }

        // Переносим ошибку с уровня "объект" на конкретное поле.
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate("dueAfter must be <= dueBefore")
                .addPropertyNode("dueAfter") // Подсветка/привязка ошибки к полю dueAfter.
                .addConstraintViolation();
        return false;
    }
}

Самое важное здесь — три строчки: disableDefaultConstraintViolation(), buildConstraintViolationWithTemplate(...) и addPropertyNode("dueAfter"). Так вы “переносите” ошибку на конкретное поле. Это особенно полезно, когда клиенту важно получить ошибку именно на уровне поля, а не абстрактное “объект невалиден”.

Можно написать идеальный валидатор, но забыть включить его в обработке запроса — и тогда он будет работать примерно как пожарная сигнализация без батареек: формально существует, но пользы мало. В Spring MVC запуск Bean Validation обычно происходит через @Valid на параметре контроллера. Для request body и для criteria-объектов принцип один и тот же, просто источник данных разный.

Cross-field validation для query criteria (@ModelAttribute)

С query-параметрами цепочка такая: Spring сначала собирает их в TaskSearchCriteria, и уже этот объект проходит @Valid. Даже если для такого аргумента @ModelAttribute часто срабатывает неявно, здесь полезно оставить его явно: так лучше видно, где query string превращается в DTO и где начинается class-level validation.

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public String listTasks(@ModelAttribute @Valid TaskSearchCriteria criteria) {
        // Spring сначала соберёт query-параметры в TaskSearchCriteria, а потом запустит @Valid.
        return "ok";
    }
}

Здесь я специально вернул String, чтобы пример остался коротким. В реальном проекте вы вернёте PagedResponse<TaskSummaryResponse>, но для темы валидации это сейчас лишнее.

Cross-field validation для JSON body (@RequestBody)

Для TaskPatchRequest схема такая же, просто данные приходят из body.

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @PatchMapping("/api/v1/tasks/{taskId}")
    public String patchTask(@RequestBody @Valid TaskPatchRequest request) {
        // Сначала Spring маппит JSON в DTO, потом выполняет валидацию.
        return "ok";
    }
}

Обратите внимание на порядок: @RequestBody @Valid. Это читается как “возьми JSON, собери DTO, а потом проверь”. Если DTO пустой ({}), @AtLeastOneFieldPresent сработает, и запрос не пойдёт дальше.

8. Типичные ошибки при class-level и cross-field validation

В class-level валидации есть несколько ловушек, которые особенно легко поймать новичку, потому что “вроде работает же”. И да, оно действительно “вроде работает” — до первого неочевидного кейса. Лучше наступить на эти грабли в лекции, чем в проде.

Ошибка №1: пытаться выразить cross-field правило через аннотацию на одном поле.
Это классика жанра: вы ставите что-то вроде @ValidDateRange на dueAfter, а потом в валидаторе пытаетесь “как-нибудь” достать dueBefore. В итоге либо вы не сможете, либо придёте к рефлексии и грусти. Если правило зависит от нескольких полей — поднимайте его на уровень типа, и валидатор получит весь объект честно и прозрачно.

Ошибка №2: превращать class-level валидатор в мини‑сервис с бизнес‑логикой.
Очень соблазнительно в isValid() сходить в TaskService и спросить “а такая задача вообще существует?” или “а этот статус можно выставить?”. Но это уже не input validation, а проверка, зависящая от текущего состояния системы. Как только валидатор начинает зависеть от состояния, вы теряете предсказуемость, усложняете тестирование и размываете границу слоёв.

Ошибка №3: считать, что null всегда должен давать false.
В Bean Validation часто принято: null — “валидно” для конкретного правила, а обязательность проверяется отдельной аннотацией (@NotNull, @NotBlank). Если вы в каждом валидаторе начнёте делать null -> false, вы получите двойные ошибки, странные сообщения и сложную поддержку. Исключения бывают, но они должны быть осознанными.

Ошибка №4: выдавать слишком общее сообщение и не привязывать нарушение к полю.
Когда cross-field правило ломается, сообщение вида “criteria is invalid” вроде бы правдивое, но бесполезное. Если вы можете прикрепить ошибку к конкретному полю через ConstraintValidatorContext — делайте это. Клиенту (и вашему будущему “я”) будет проще понять, где проблема и что исправлять.

Ошибка №5: делать один class-level валидатор “на всё”, потому что так меньше файлов.
Файл действительно будет один, но он станет похож на “швейцарский нож”, которым пытаются и суп мешать, и винты крутить, и забор чинить. Cross-field валидатор должен отвечать за одно правило: диапазон дат — отдельно, конфликт фильтров — отдельно, пустой PATCH — отдельно. Это не бюрократия, это способ сохранить читаемость контракта.

1
Задача
Spring REST & MVC, 17 уровень, 2 лекция
Недоступна
Class-level валидация диапазона дат для criteria DTO
Class-level валидация диапазона дат для criteria DTO
1
Задача
Spring REST & MVC, 17 уровень, 2 лекция
Недоступна
PATCH-запрос с правилом «хотя бы одно поле должно быть заполнено»
PATCH-запрос с правилом «хотя бы одно поле должно быть заполнено»
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ