Опирается на правила:
R-VLD-WHERE-1…R-VLD-WHERE-4иR-VLD-WHERE-X1…R-VLD-WHERE-X4из Validation Style Guide → раздел 1. Где валидируем.
Важно знать
- В UCP-сервисе валидация живёт в трёх местах: контроллер (входной HTTP DTO),
@ConfigurationProperties(конфиг), агрегат (доменные инварианты). Других не должно быть.- Контроллер:
@Valid @RequestBody CreateOrderRequest req. Spring сам броситMethodArgumentNotValidException, edge-handler смапит в 400 с массивомviolationsпо RFC 9457.- Конфиг:
@Validatedна@ConfigurationProperties-классе. Невалидный конфиг →BeanCreationExceptionна старте, не «сервис поднялся с битым флагом».- Домен:
if (status != CREATED) throw new OrderDomainException(...)в методах агрегата. Не@Min/@NotNullна полях.- Nested DTO —
@Validобязателен на поле. Без него вложенный список не валидируется.- Handler не валидирует. К моменту входа в Handler команда уже чистая (контроллер уже отработал
@Valid).- Manual
if (cmd.amount() < 0) throw ...в Handler — антипаттерн. Теряется единый форматviolationsв ProblemDetails.
Валидация в Spring-проекте часто превращается в кашу: где-то @Valid, где-то @Validated, где-то if-цепочка в сервисе, где-то проверка в SQL. В UCP-стиле — три явных места, у каждого свой инструмент и свой формат ответа. Раскрытие раздела 1 гайда.
Место 1: контроллер — @Valid на входном DTO
R-VLD-WHERE-1: первая линия защиты, до того как невалидные данные дойдут до handler-а.
@RestController
@RequiredArgsConstructor
class OrderController implements OrdersApi {
private final UseCaseDispatcher dispatcher;
@Override
public ResponseEntity<OrderJson> createOrder(@Valid @RequestBody CreateOrderRequest req) {
CreateOrder cmd = orderMapper.toCommand(req);
OrderId id = dispatcher.dispatch(cmd);
return ResponseEntity.status(201).body(orderMapper.toJson(id));
}
}
Как работает:
- Spring видит
@Validна параметре и прогоняет DTO через Jakarta Validation. - Если в
CreateOrderRequestесть нарушения (@NotNull,@Size,@Pattern), бросаетсяMethodArgumentNotValidException. @RestControllerAdviceловит его, формирует ProblemDetails с массивомviolations(см.R-ERR-5в REST API style guide).- Клиент получает 400 с понятным списком ошибок: какое поле, что не так, на каком уровне.
Дополнительные правила:
@Validна nested-полях (R-VLD-WHERE-4).@Valid @NotEmpty List<OrderItemRequest> items— без@ValidJakarta не пройдёт внутрь списка.implements <Tag>Apiиз generated OpenAPI (R-VLD-OAS-4) — это уже про OpenAPI-first, см. соответствующую статью.- Path/query параметры —
@Validatedна классе контроллера +@Min/@Patternна параметрах метода. ТогдаConstraintViolationExceptionловится тем же edge-handler-ом.
Место 2: конфиг — @Validated на @ConfigurationProperties
R-VLD-WHERE-2: невалидный конфиг должен ломать старт, не первый запрос.
@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(
@NotBlank String baseUrl,
@NotNull Duration connectTimeout,
@NotNull Duration readTimeout,
@Min(1) @Max(100) int maxConcurrent,
String apiKey // optional
) {}
Что происходит без @Validated:
application.ymlпуст или содержит опечатку (base-url:вместоbaseUrl:).- Spring создаёт бин с
null-ами и нулями. - Сервис стартует «успешно».
- Первый запрос к Sber падает с
NullPointerExceptionгде-то в middle of stack trace. - Оператор разбирается час.
С @Validated:
- На старте бин валидируется.
- При первой проблеме —
BeanCreationExceptionс точным указанием поля. - Сервис не стартует. Healthcheck сразу красный, deploy откатывается.
Это и есть «fail fast» — отказ как можно раньше, с понятным сообщением. Подробно — в Configuration validation.
Место 3: домен — exception в методе агрегата
R-VLD-WHERE-3: доменные инварианты — не Jakarta. Aggregate проверяет состояние внутри методов и бросает доменное исключение.
public final class Order extends AggregateRoot<OrderId> {
private OrderStatus status;
private List<OrderItem> items;
public void confirm() {
if (this.status != OrderStatus.CREATED) {
throw new OrderAlreadyConfirmedException(this.id, this.status);
}
if (this.items.isEmpty()) {
throw new EmptyOrderException(this.id);
}
this.status = OrderStatus.CONFIRMED;
this.confirmedAt = Instant.now();
registerEvent(new OrderConfirmed(this.id, this.confirmedAt));
}
}
Что хорошего:
- Правило живёт рядом с состоянием.
confirm()знает проstatusиitems— там же проверяется. - Конкретный exception-тип.
OrderAlreadyConfirmedException→ edge-handler даёт 409 сcode=ORDER_ALREADY_CONFIRMED(см. Error Handling: иерархия). - Метрики разделимы.
app_errors_total{exception="OrderAlreadyConfirmedException"}отдельная серия, можно алёртить.
Почему не Jakarta:
@Min(1) int quantityна полеOrderItemсработает один раз — на конструировании. Ноquantityможет меняться через бизнес-метод; Jakarta не пересмотрит уже сконструированный объект.- Jakarta-аннотация на агрегате путает читателя: непонятно, это инвариант домена или контракт DTO.
- Доменный exception несёт бизнес-контекст (
OrderId, текущийstatus); Jakarta — только текст сообщения.
Handler не валидирует
Главное правило, неявное в R-VLD-WHERE-1..3: в Handler-е никакой валидации нет. Команда пришла из контроллера уже валидированной; домен-проверки делает агрегат при изменении состояния.
// ПЛОХО — Handler с валидацией
@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {
public OrderId handle(CreateOrder cmd) {
if (cmd.customerId() == null) { // ← дубль @NotNull в DTO
throw new IllegalArgumentException("customerId required");
}
if (cmd.items().stream().anyMatch(i -> i.quantity() <= 0)) { // ← дубль @Min
throw new IllegalArgumentException("quantity must be positive");
}
// ...
}
}
// ХОРОШО — Handler доверяет валидации на edge
@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {
private final CustomerRepository customerRepository;
private final OrderRepository orderRepository;
private final OrderFactory orderFactory;
@Override
@Transactional
public OrderId handle(CreateOrder cmd) {
Customer customer = customerRepository.findById(cmd.customerId(), NO_LOCK)
.orElseThrow(() -> new CustomerNotFoundException(cmd.customerId()));
Order order = orderFactory.createFor(customer, cmd.items()); // правила домена внутри
orderRepository.save(order);
return order.id();
}
}
Что плохо в первом варианте (R-VLD-WHERE-X1):
- Дублирование. Контроллер уже проверил
@NotNull customerIdчерез@Valid. Дубль в Handler-е — лишний код, который рассинхронизируется при изменении правил. - Потеряется формат
violations.IllegalArgumentExceptionв Handler даёт 500 (или 400 без структурированныхviolations). Клиент видит «что-то не так», не «поле X пустое». - Перепутаны слои. Handler — это оркестратор, а не валидатор. Бизнес-правила (нельзя создать Order для деактивированного Customer) проверяются в Factory / агрегате, не в Handler.
Дублирование Jakarta на UseCase command — лишнее
R-VLD-WHERE-X2: соблазн — повесить @NotNull на поля CreateOrder record-а и валидировать его тоже через @Validated на Handler.
// ПЛОХО — двойная Jakarta-валидация
public record CreateOrder(
@NotNull CustomerId customerId, // ← дубль из CreateOrderRequest
@NotEmpty List<@Valid CreateOrderItem> items
) implements UseCaseCommand {}
@Validated
@Component
class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> { ... }
Что не так:
- Два места правды.
@NotNullна DTO +@NotNullна command. При изменении правила (стало optional) — два места правки. - Generated DTO ≠ command. Generated
CreateOrderRequestвалидирован OpenAPI-аннотациями. Command — pure-domain. Перенос Jakarta в core — нарушениеR-MOD-2(core без Spring/Jakarta). - Бесполезная работа. К моменту, когда
dispatcher.dispatch(cmd)вызван, проверка уже прошла на контроллере. Повторная — холостой run.
Команда — это внутренний объект, который собирается mapper-ом из валидного DTO. Дополнительной валидации не требуется.
Nested DTO — @Valid обязателен
R-VLD-WHERE-4: без @Valid на nested-поле вложенный список не валидируется.
// ПЛОХО — @Valid забыт, items внутри не валидируются
public record CreateOrderRequest(
@NotNull CustomerId customerId,
@NotEmpty List<OrderItemRequest> items // ← без @Valid!
) {}
public record OrderItemRequest(
@NotNull ProductId productId,
@Min(1) int quantity
) {}
@NotEmpty проверит, что список непустой. Но если внутри OrderItemRequest поля невалидны (productId == null, quantity == 0), Jakarta их не увидит.
// ХОРОШО — @Valid на nested
public record CreateOrderRequest(
@NotNull CustomerId customerId,
@NotEmpty @Valid List<OrderItemRequest> items
) {}
С @Valid Jakarta заходит внутрь каждого OrderItemRequest и проверяет правила. В violations появятся пути типа items[0].quantity, items[3].productId — клиент видит, какой именно элемент списка ошибочный.
То же касается nested objects (@Valid Address shippingAddress), Optional-полей с проверками (@Valid Optional<PromoCode> promo), Map-значений.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Manual if (cmd.x < 0) throw в Handler для входной валидации | R-VLD-WHERE-X1 | @Valid на контроллере, Jakarta-аннотации на DTO |
| Дублирование Jakarta на UseCase command (record) | R-VLD-WHERE-X2 | Валидация один раз — на edge |
@ConfigurationProperties без @Validated | R-VLD-WHERE-X3 | @Validated на классе, fail-fast на старте |
Доменный инвариант через @Min/@NotNull на поле Aggregate | R-VLD-WHERE-X4 | Проверка в методе агрегата + бросание domain exception |
@Valid забыт на nested-DTO | R-VLD-WHERE-4 | @Valid на каждое nested-поле |
Куда дальше
- Validation → раздел 1. Где валидируем — нормативные формулировки
R-VLD-WHERE-*. - Стандартные constraints —
@NotNull,@Size,@Patternи компания. - Custom constraints — когда стандартных недостаточно.
- Configuration validation —
@ConfigurationProperties+@Validatedподробно. - OpenAPI-сгенерированные DTO — где формулируются правила: в YAML, не в коде.
- Error Handling → ProblemDetails mapping — что клиент получает после
MethodArgumentNotValidException. - DDD Tactical → Aggregate Root — почему доменные инварианты живут на агрегате.