Опирается на правила: R-VLD-GRP-1R-VLD-GRP-2 и R-VLD-GRP-X1R-VLD-GRP-X2 из Validation Style Guide → раздел 4. Validation groups.

Важно знать

  • Validation groups — механизм «один класс DTO, разные required-поля в разных контекстах». Не для всего подряд.
  • Кейс: OrderRequest для POST /orders (создание) и PATCH /orders/{id} (частичное обновление). На create customerId обязателен; на update — нет.
  • Group — пустой маркер-interface. Не extends Default, без методов. Имя в формате OnCreate, OnUpdate, OnConfirm.
  • На контроллере: @Validated(OnCreate.class) @RequestBody OrderRequest req. @Valid без group использует Default-группу.
  • На constraint: @NotNull(groups = OnCreate.class) — правило применяется только в указанной группе.
  • Применять узко. Только когда DTO реально один и тот же в нескольких сценариях. Если нет — два разных DTO лучше.
  • Цепочки @Validated({OnCreate.class, OnConfirm.class, OnPay.class}) — флаг, что класс делает слишком много. Разбиваем.
  • На UCP-стиле проекта groups встречаются редко — обычно Create и Update уже отделены в OpenAPI как разные схемы (CreateOrderRequest, UpdateOrderRequest).

Validation groups — самый редко используемый механизм Jakarta в проекте. На 50 DTO встречается раз-два. Понимать его нужно — но дефолт «два разных DTO» побеждает в 90% случаев. Раскрытие раздела 4 гайда.

Когда применяем

R-VLD-GRP-1: тот же класс DTO нужен в двух и более сценариях с разными required-полями.

Канонический пример — частичное обновление:

public interface OnCreate {}
public interface OnUpdate {}

public record OrderRequest(
    @NotNull(groups = OnCreate.class)                   // required только при создании
    Long customerId,
    @NotNull                                             // required всегда
    Money totalAmount,
    @Size(max = 1000)
    String comment
) {}

Контроллер:

@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
class OrderController {

    @PostMapping
    public ResponseEntity<OrderJson> create(@Validated(OnCreate.class) @RequestBody OrderRequest req) {
        // customerId проверен на @NotNull, totalAmount тоже
    }

    @PatchMapping("/{id}")
    public ResponseEntity<OrderJson> update(@PathVariable Long id,
                                            @Validated(OnUpdate.class) @RequestBody OrderRequest req) {
        // customerId может быть null (не меняем); totalAmount всё ещё required
    }
}

Что произойдёт:

  • @Validated(OnCreate.class) запустит Jakarta с группой OnCreate. Сработают @NotNull(groups = OnCreate.class) и все constraints, у которых не указан groups.
  • @Validated(OnUpdate.class) запустит с OnUpdate. @NotNull(groups = OnCreate.class) не сработает. @NotNull без groups (на totalAmount) сработает.

Важная тонкость: constraint без groups принадлежит группе Default. @Validated(OnCreate.class) пропустит Default-constraints; чтобы их включить, нужно @Validated({Default.class, OnCreate.class}).

Group — пустой маркер-interface

R-VLD-GRP-2: group — это маркер, без методов, без наследования.

// ХОРОШО
public interface OnCreate {}
public interface OnUpdate {}

// ПЛОХО
public interface OnCreate extends Default {}    // ← extends Default
public interface OnCreate {                     // ← с методом
    boolean isCreating();
}

Зачем именно маркер:

  • Jakarta использует только class-token. Внутри Validator-а group идентифицируется по .class, методы не вызываются.
  • extends Default непредсказуем. Тогда @Validated(OnCreate.class) запустит и Default-группу, что часто не то, что нужно. Лучше явно: @Validated({Default.class, OnCreate.class}).
  • Имя. OnCreate, OnUpdate, OnConfirm — читаются как «при создании», «при обновлении», «при подтверждении». Это естественная грамматика для контекста.

Расположение groups — рядом с DTO, для которого используются:

core/order/api/
  OrderRequest.java
  OnCreate.java
  OnUpdate.java

Не плодим общий validation-groups/ пакет — group, привязанная к одному DTO, остаётся с ним.

Когда не применяем

R-VLD-GRP-X1: группы для «строгая/мягкая валидация» — антипаттерн.

// ПЛОХО — две группы для одного DTO с разной "строгостью"
public interface Strict {}
public interface Loose {}

public record OrderRequest(
    @NotNull(groups = {Strict.class, Loose.class})
    Long customerId,
    @NotNull(groups = Strict.class) @Size(max = 100, groups = {Strict.class, Loose.class})
    String comment
) {}

Что не так: если у нас два разных уровня «строгости», это означает, что мы передаём разные намерения через один объект. Это два разных use-case-а, у которых разные требования. Решение — два разных DTO:

// ХОРОШО — Draft и Final имеют разные правила
public record DraftOrderRequest(
    @Size(max = 1000) String comment
    // customerId optional, totalAmount optional
) {}

public record FinalOrderRequest(
    @NotNull Long customerId,
    @NotNull Money totalAmount,
    @Size(max = 1000) String comment
) {}

Два DTO — это, на первый взгляд, больше кода. Но: каждый имеет ясный контракт, OpenAPI-схема для каждого отдельная, mapper в команду тоже отдельный. Один DTO с группами — это объект, который притворяется, что он два, а нагрузка на читателя кода удваивается.

R-VLD-GRP-X2: цепочки @Validated({OnCreate.class, OnConfirm.class, OnPay.class}) — флаг, что класс делает слишком много.

// ПЛОХО — три фазы жизненного цикла в одном DTO
public record OrderRequest(
    @NotNull(groups = OnCreate.class) Long customerId,
    @NotEmpty(groups = OnCreate.class) List<OrderItemRequest> items,
    @NotNull(groups = OnConfirm.class) Address shippingAddress,
    @NotNull(groups = OnPay.class) PaymentMethod paymentMethod
) {}

Это попытка протащить весь lifecycle Order-а через один request-объект. Лучше:

  • CreateOrderRequest — поля для шага «создание».
  • ConfirmOrderRequest — для подтверждения.
  • PayOrderRequest — для оплаты.

Каждый — отдельный endpoint, отдельный DTO, отдельный mapper. Командные объекты в core/order/usecase/command/ тоже отдельные: CreateOrder, ConfirmOrder, PayOrder. Так выглядит правильное разделение в UCP-стиле — см. Aggregate Root и Use Case Pattern.

В UCP-проекте групп почти нет

В стиле проекта, где DTO генерируются из OpenAPI (см. OpenAPI-сгенерированные DTO), groups встречаются редко. OpenAPI оперирует разными схемами для разных операций:

paths:
  /orders:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
  /orders/{id}:
    patch:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOrderRequest'

components:
  schemas:
    CreateOrderRequest:
      required: [customerId, items]
      properties:
        customerId: {type: integer}
        items: {type: array, ...}
    UpdateOrderRequest:
      properties:
        items: {type: array, ...}      # customerId не required

Generated Java — два разных класса: CreateOrderRequest и UpdateOrderRequest. Никаких groups, всё разделено на уровне схемы. Контроллер тривиален.

Groups имеют смысл в handcrafted DTO (когда нет OpenAPI codegen, обычно — внутренние конфиги, админ-эндпоинты, тестовые утилиты). На основных API — почти всегда нет.

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

АнтипаттернПравилоЧто взамен
Группы для «строгая/мягкая валидация»R-VLD-GRP-X1Два разных DTO с разными правилами
Цепочки @Validated({A.class, B.class, C.class})R-VLD-GRP-X2Разбить DTO на несколько по lifecycle-шагам
Group extends DefaultR-VLD-GRP-2Чистый маркер: interface OnCreate {}
Метод внутри group-interfaceR-VLD-GRP-2Пустой маркер, методы не используются
Groups в generated DTO из OpenAPIРазные схемы в YAML → разные generated DTO

Куда дальше