Опирается на правила: R-VLD-WHERE-1R-VLD-WHERE-4 и R-VLD-WHERE-X1R-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));
    }
}

Как работает:

  1. Spring видит @Valid на параметре и прогоняет DTO через Jakarta Validation.
  2. Если в CreateOrderRequest есть нарушения (@NotNull, @Size, @Pattern), бросается MethodArgumentNotValidException.
  3. @RestControllerAdvice ловит его, формирует ProblemDetails с массивом violations (см. R-ERR-5 в REST API style guide).
  4. Клиент получает 400 с понятным списком ошибок: какое поле, что не так, на каком уровне.

Дополнительные правила:

  • @Valid на nested-полях (R-VLD-WHERE-4). @Valid @NotEmpty List<OrderItemRequest> items — без @Valid Jakarta не пройдёт внутрь списка.
  • 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 без @ValidatedR-VLD-WHERE-X3@Validated на классе, fail-fast на старте
Доменный инвариант через @Min/@NotNull на поле AggregateR-VLD-WHERE-X4Проверка в методе агрегата + бросание domain exception
@Valid забыт на nested-DTOR-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 — почему доменные инварианты живут на агрегате.