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

Важно знать

  • Jakarta Validation groups в Python не существуют. @Validated(OnCreate.class) — это Java-конструкция; в Python её нет и не нужно изобретать.
  • Идиома Python — отдельные Pydantic-модели для каждого сценария: CreateOrderRequest и UpdateOrderRequest.
  • Общие поля — в базовом классе (OrderRequestBase); сценарно-специфичные required/optional — в подклассах.
  • Если модели очень похожи, но required-поля различаются — правильнее два класса, чем один с флагом-переключателем.
  • Один класс, обслуживающий 3+ сценария через @model_validator(mode="after") с ветвлением — запах «класс делает слишком много». Разбиваем.
  • В UCP-стиле подход code-first (Pydantic) уже форсирует разные модели: каждый эндпоинт принимает свою модель.

Validation groups в Jakarta появились, потому что Java favours reuse через наследование класса с аннотациями. Python с Pydantic v2 решает ту же задачу иначе: маленькие явные классы дешевле в сопровождении, чем один класс с group-флагами. Раскрытие раздела 4 гайда.

Идиома Python: отдельные модели

R-VLD-GRP-1: разные required-поля в разных сценариях — разные классы.

from uuid import UUID
from decimal import Decimal
from pydantic import BaseModel, Field

class OrderRequestBase(BaseModel):
    comment: str | None = Field(None, max_length=500)

class CreateOrderRequest(OrderRequestBase):
    customer_id: UUID
    items: list[OrderItemRequest] = Field(min_length=1)
    total_amount: Decimal = Field(gt=0, decimal_places=2)

class UpdateOrderRequest(OrderRequestBase):
    comment: str | None = Field(None, max_length=500)

Эндпоинты:

@router.post("/v1/orders", status_code=201)
async def create_order(req: CreateOrderRequest, ...) -> OrderResponse:
    ...

@router.patch("/v1/orders/{order_id}")
async def update_order(
    order_id: UUID,
    req: UpdateOrderRequest,
    ...,
) -> OrderResponse:
    ...

customer_id и items — required только при создании, отсутствуют в UpdateOrderRequest. Нет никаких group-флагов, нет if create_mode.

Базовый класс для общих полей

Если сценарии действительно разделяют значительную часть полей, вводим базу:

class ProductRequestBase(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    sku: str = Field(min_length=1, max_length=64)
    category_id: UUID

class CreateProductRequest(ProductRequestBase):
    price: Decimal = Field(gt=0, decimal_places=2)
    stock_quantity: int = Field(ge=0)
    description: str | None = Field(None, max_length=5000)

class UpdateProductRequest(ProductRequestBase):
    price: Decimal | None = Field(None, gt=0, decimal_places=2)
    stock_quantity: int | None = Field(None, ge=0)
    description: str | None = Field(None, max_length=5000)

ProductRequestBase — общий контракт (name, sku, category_id всегда required). Дочерние классы добавляют или ослабляют остальные поля. Это прямой аналог base-класса с Default-группой в Jakarta.

Частичное обновление через опциональные поля

Для PATCH-эндпоинтов все поля optional — «меняем только то, что передано»:

class PatchOrderRequest(BaseModel):
    comment: str | None = None
    shipping_address: AddressRequest | None = None
    priority: OrderPriority | None = None

В Handler различаем «не передано» (поля нет в JSON) и «передано null» через model_fields_set:

async def handle(self, cmd: PatchOrder) -> None:
    order = await self._orders.find_by_id(cmd.order_id)
    if "comment" in cmd.set_fields:
        order.set_comment(cmd.comment)
    if "priority" in cmd.set_fields:
        order.set_priority(cmd.priority)
    await self._orders.save(order)

Это чище, чем sentinel-объект UNSET, и точнее, чем проверка != None.

@model_validator по контексту — редкий кейс

Иногда одна модель намеренно обслуживает два слабо различающихся сценария, и разделять — избыточно. Тогда допустим @model_validator(mode="after") с логикой по флагу-полю:

from enum import Enum

class TransferType(Enum):
    INTERNAL = "INTERNAL"
    EXTERNAL = "EXTERNAL"

class CreateTransferRequest(BaseModel):
    type: TransferType
    amount: Decimal = Field(gt=0, decimal_places=2)
    from_account_id: UUID
    to_account_id: UUID | None = None
    to_iban: str | None = None

    @model_validator(mode="after")
    def _destination_by_type(self) -> "CreateTransferRequest":
        if self.type == TransferType.INTERNAL and self.to_account_id is None:
            raise ValueError("Для внутреннего перевода обязателен to_account_id")
        if self.type == TransferType.EXTERNAL and self.to_iban is None:
            raise ValueError("Для внешнего перевода обязателен to_iban")
        return self

Правило R-VLD-GRP-X2: если к одной модели добавляется третий TransferType с новыми required — это сигнал разбить модели. Одна модель = максимум два взаимоисключающих сценария.

Когда точно два разных класса

R-VLD-GRP-X1: один класс с «строгим/мягким» режимом — два разных DTO.

# ПЛОХО — mode-флаг в одной модели
class OrderRequest(BaseModel):
    strict: bool = False
    customer_id: UUID | None = None
    items: list[...] | None = None

    @model_validator(mode="after")
    def _mode_check(self) -> "OrderRequest":
        if self.strict:
            if self.customer_id is None:
                raise ValueError("...")
            if not self.items:
                raise ValueError("...")
        return self

# ХОРОШО
class CreateOrderRequest(BaseModel):
    customer_id: UUID
    items: list[OrderItemRequest] = Field(min_length=1)

class DraftOrderRequest(BaseModel):
    customer_id: UUID | None = None
    items: list[OrderItemRequest] = Field(default_factory=list)

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

АнтипаттернПравилоЧто взамен
Один класс с mode/strict-флагом переключения requiredR-VLD-GRP-X1Два класса: CreateOrderRequest, DraftOrderRequest
@model_validator с 3+ ветками по типу/флагуR-VLD-GRP-X2Разбить на отдельные модели
Все поля optional в одной «универсальной» модели для create и updateЯвные CreateRequest (required поля) и UpdateRequest (optional поля)

Куда дальше

  • Validation → раздел 4. Validation groups — нормативные формулировки R-VLD-GRP-*.
  • python/cross-field-validation — @model_validator для правил между полями, не для групп.
  • python/where-to-validate — почему валидация — на модели, не в Handler.
  • python/openapi-generated-dto — как code-first контракт отражает разные сценарные модели в OpenAPI-схеме.