Опирается на правила: R-VLD-OAS-1R-VLD-OAS-6 и R-VLD-OAS-X1R-VLD-OAS-X5 из Validation Style Guide → раздел 6. OpenAPI-сгенерированные DTO.

Важно знать

  • OpenAPI-first. Все validation-правила входных DTO живут в YAML, не в Java. Codegen превращает их в Jakarta-аннотации автоматически.
  • useBeanValidation = true в openapi-generator config — без неё generated DTO без аннотаций, @Valid silent-passes невалидные данные.
  • Маппинг OpenAPI keyword → Jakarta: required@NotNull, minLength@Size(min=...), format: email@Email, pattern@Pattern, $ref@Valid на nested.
  • Контроллер implements <Tag>Api (generated interface). Java-код контроллера тривиальный — все аннотации уже на параметрах generated interface.
  • Custom constraint, не сводимый к OpenAPI, — на wrapper-class в коде проекта. Generated DTO не правим руками.
  • Двойной контракт generated vs UseCase. Generated DTO валидируется на edge через @Valid; UseCase command (record) — не валидируется повторно. Команда пришла «уже чистой».
  • Дописывать @Valid/@NotNull руками в build/generated/... — затрётся при следующем compileJava/openApiGenerate.
  • Handcrafted class CreateOrderRequest без @Generated — нарушение R-OAS-1/BS-20. Вырезать, перенести в YAML.

OpenAPI-first означает, что контракт API — это YAML. Java-код контроллеров и DTO — производная от него. Это даёт два следствия: одно место правды (изменил YAML — изменилось всё), и невозможность рассинхронизации между документацией и реализацией. Валидация — часть контракта, поэтому она тоже в YAML. Раскрытие раздела 6 гайда.

OpenAPI-first: правила в YAML

R-VLD-OAS-1: если правило выражается стандартными OpenAPI keywords — пишем в YAML, не дописываем в коде.

# src/main/resources/openapi/order-service.openapi.yaml
components:
  schemas:
    CreateOrderRequest:
      type: object
      required:
        - customerId
        - items
      properties:
        customerId:
          type: integer
          format: int64
          minimum: 1
        email:
          type: string
          format: email
        phone:
          type: string
          pattern: '^\+7\d{10}$'
        totalAmount:
          type: number
          format: double
          minimum: 0.01
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/OrderItemRequest'

После ./gradlew openApiGenerate появляется build/generated/.../CreateOrderRequest.java:

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", ...)
public class CreateOrderRequest {

    @NotNull
    @Min(1)
    private Long customerId;

    @Email
    private String email;

    @Pattern(regexp = "^\\+7\\d{10}$")
    private String phone;

    @NotNull
    @DecimalMin("0.01")
    private Double totalAmount;

    @NotEmpty
    @Size(min = 1, max = 100)
    @Valid
    private List<@Valid OrderItemRequest> items;
}

Все аннотации уже на месте. Разработчик в контроллере пишет:

@RestController
@RequiredArgsConstructor
class OrderController implements OrdersApi {

    @Override
    public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderRequest req) {
        // ...
    }
}

@Valid на параметре запускает Jakarta. Все правила, описанные в YAML, проверяются. Никакого дублирования.

useBeanValidation = true обязательно

R-VLD-OAS-2: без этой опции generated DTO без аннотаций, @Valid ничего не делает.

// <module>/build.gradle.kts
openApiGenerate {
    generatorName.set("spring")
    inputSpec.set("$projectDir/src/main/resources/openapi/order-service.openapi.yaml")
    outputDir.set("$buildDir/generated/openapi")
    apiPackage.set("ru.vikulinva.order.api.gen")
    modelPackage.set("ru.vikulinva.order.api.gen.dto")
    configOptions.set(mapOf(
        "useSpringBoot3" to "true",
        "useJakartaEe" to "true",
        "useBeanValidation" to "true",       // ← без этого аннотаций не будет
        "interfaceOnly" to "true",
        "skipDefaultInterface" to "true"
    ))
}

Что произойдёт без флага (R-VLD-OAS-X2):

  • Generated CreateOrderRequest — POJO без @NotNull, @Size, @Email.
  • @Valid на параметре контроллера запустит Jakarta, но проверять нечего.
  • Невалидные данные ({} без customerId) проходят на Handler.
  • Handler падает с NullPointerException в первой же строчке.
  • Клиент получает 500 вместо ожидаемого 400.

useBeanValidation = true — это обязательная настройка для всех openapi-generator конфигов в проекте, неважно spring (server-side) или spring-restclient (outbound). См. также R-RES-OAS-2 в Resilience Style Guide для outbound-клиентов.

Маппинг keyword → Jakarta annotation

R-VLD-OAS-3: знание соответствия позволяет писать правильный YAML.

OpenAPI keywordJava typeJakarta annotation
required: [field]object property@NotNull
minLength: 1String@Size(min=1)
maxLength: NString@Size(max=N)
pattern: '^...$'String@Pattern(regexp = "^...$")
format: emailString@Email
format: uuidUUID(тип, без аннотации)
format: dateLocalDate(тип)
format: date-timeOffsetDateTime(тип)
minimum: Nint/long@Min(N)
minimum: 0.01BigDecimal/Double@DecimalMin("0.01")
maximum: Nnumbers@Max(N)
exclusiveMinimum: true + minimum: 0numbers@DecimalMin(value = "0", inclusive = false)
minItems: NList/Set@Size(min=N)
maxItems: NList/Set@Size(max=N)
uniqueItems: truearrayне генерируется — нужен custom validator или Set<X> тип
enum: [A, B]java enum(тип)
$ref: '#/.../X'nested object@Valid (рекурсивная валидация)

Несколько комментариев к таблице:

  • format: uuid — генератор подставит UUID в качестве типа. Парсинг строки в UUID происходит при десериализации Jackson — если входит мусор, ошибка ловится до Jakarta. Аннотация не нужна.
  • minimum: 0.01 на number — генератор выберет Double или BigDecimal в зависимости от format. Если format: doubleDouble + @DecimalMin; если без format — обычно BigDecimal.
  • uniqueItems: true — спека OpenAPI 3.0 требует, но generator не имеет Jakarta-эквивалента. Используем Set<X> вместо List<X> либо custom constraint.
  • $ref — генератор автоматически расставляет @Valid на nested-полях. Это сэкономило бы строчку в handcrafted DTO, но в generated происходит бесплатно.

Контроллер implements generated Api

R-VLD-OAS-4: контроллер реализует generated interface, не пишет @RestController с handcrafted маппингом.

// Generated by openapi-generator
public interface OrdersApi {
    @PostMapping(value = "/orders", consumes = "application/json")
    ResponseEntity<OrderJson> createOrder(
        @Valid @RequestBody CreateOrderRequest createOrderRequest
    );

    @GetMapping(value = "/orders/{id}")
    ResponseEntity<OrderJson> getOrder(
        @PathVariable("id") @Min(1) Long id
    );
}

// Our code
@RestController
@RequiredArgsConstructor
class OrderController implements OrdersApi {

    private final UseCaseDispatcher dispatcher;
    private final OrderApiMapper mapper;

    @Override
    public ResponseEntity<OrderJson> createOrder(CreateOrderRequest req) {
        OrderId id = dispatcher.dispatch(mapper.toCommand(req));
        return ResponseEntity.status(201).body(mapper.toJson(id));
    }

    @Override
    public ResponseEntity<OrderJson> getOrder(Long id) {
        Order order = dispatcher.dispatch(new GetOrder(new OrderId(id)));
        return ResponseEntity.ok(mapper.toJson(order));
    }
}

Что даёт:

  • Все аннотации (@Valid, @PathVariable, @Min) — на generated interface. Контроллер их «получает» через implements.
  • Сигнатуры всегда соответствуют OpenAPI. Меняем CreateOrderRequest в YAML — generated interface меняется — контроллер падает на компиляции, пока не обновит метод.
  • Bottom-up рефактор. Если YAML говорит «POST стал PATCH» — OrdersApi это отразит, контроллер должен поправиться. Без generated — изменение в YAML и в коде делается двумя отдельными шагами, которые легко рассинхронизировать.

Custom constraint через wrapper-class

R-VLD-OAS-5: правило, не сводимое к OpenAPI keywords, — не дописываем в generated, а оборачиваем.

Вариант А (рекомендуется): правило выражается через pattern — пишем в OpenAPI напрямую.

# вместо custom @RussianPhone в коде —
phone:
  type: string
  pattern: '^\+7\d{10}$'

Generated DTO получает @Pattern(regexp = "^\\+7\\d{10}$"). Java-аннотация @RussianPhone дополнительно не нужна.

Вариант Б: правило не сводится к pattern (бизнес-проверка контрольной суммы ИНН). Применяем custom constraint на wrapper-class.

// adapter/in/rest/order/wrapper/
public record CreateOrderInput(
    @Valid @RussianInn String inn,
    @Valid CreateOrderRequest body
) {}

@RestController
class OrderController implements OrdersApi {

    @Override
    public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderInput input) {
        // generated CreateOrderRequest валидируется через @Valid
        // дополнительный @RussianInn на inn — кастомный constraint
    }
}

Generated CreateOrderRequest регенерируется без потерь; custom-логика — на wrapper, который мы пишем сами.

Двойной контракт generated vs UseCase

R-VLD-OAS-6: generated DTO валидируется. UseCase command — не валидируется повторно.

// Generated, валидируется на edge через @Valid
public class CreateOrderRequest {
    @NotNull private Long customerId;
    @NotEmpty @Valid private List<OrderItemRequest> items;
}

// UseCase command, в core/, БЕЗ Jakarta
public record CreateOrder(
    CustomerId customerId,                       // ← без @NotNull
    List<CreateOrderItem> items                  // ← без @NotEmpty
) implements UseCaseCommand {}

Почему так:

  • Команда пришла из контроллера уже чистой. К моменту dispatcher.dispatch(cmd) все Jakarta-аннотации на DTO уже сработали. Дубль на command — бесполезная работа.
  • core/ не зависит от Jakarta (R-MOD-2). jakarta.validation.* в core/order/usecase/command/ — нарушение архитектуры.
  • Domain-инварианты — отдельный слой. «Order должен иметь хотя бы один item» проверяется в конструкторе Order или в OrderFactory, не Jakarta. См. Где валидировать.

Маппер из DTO в command — простой, без проверок:

@Component
class OrderApiMapper {

    public CreateOrder toCommand(CreateOrderRequest req) {
        return new CreateOrder(
            new CustomerId(req.getCustomerId()),
            req.getItems().stream().map(this::toItem).toList()
        );
    }
}

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

АнтипаттернПравилоЧто взамен
Дописывать @Valid/@NotNull руками в build/generated/...R-VLD-OAS-X1Правило в OpenAPI YAML; регенерация
useBeanValidation = false или отсутствие опцииR-VLD-OAS-X2useBeanValidation = true обязательно
Class-level constraint (@DateRange) на generated DTOR-VLD-OAS-X3Wrapper-class в коде с class-level constraint
Дублирование validation в YAML и в JavaR-VLD-OAS-X4Один источник правды — OpenAPI YAML
Handcrafted class CreateOrderRequest без @Generated для inboundR-VLD-OAS-X5Перенести в OpenAPI, генерировать
@RestController с handcrafted маппингом вместо implements <Tag>ApiR-VLD-OAS-4implements <Tag>Api из generated

Куда дальше