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 — отдельно. Это не бюрократия, это способ сохранить читаемость контракта.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ