Опирается на правила:
R-VLD-GRP-1…R-VLD-GRP-2иR-VLD-GRP-X1…R-VLD-GRP-X2из Validation Style Guide → раздел 4. Validation groups.
Важно знать
- Validation groups — механизм «один класс DTO, разные required-поля в разных контекстах». Не для всего подряд.
- Кейс:
OrderRequestдляPOST /orders(создание) иPATCH /orders/{id}(частичное обновление). На createcustomerIdобязателен; на 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 Default | R-VLD-GRP-2 | Чистый маркер: interface OnCreate {} |
| Метод внутри group-interface | R-VLD-GRP-2 | Пустой маркер, методы не используются |
| Groups в generated DTO из OpenAPI | — | Разные схемы в YAML → разные generated DTO |
Куда дальше
- Validation → раздел 4. Validation groups — нормативные формулировки
R-VLD-GRP-*. - Где валидировать — про
@Validvs@Validatedна контроллере. - OpenAPI-сгенерированные DTO — почему в проекте обычно нет groups.
- REST API → разделение Create / Update — два DTO как стандарт для разных операций.
- Use Case Pattern — почему команда
CreateOrder≠UpdateOrderв коде.