Опирается на правила:
R-VLD-GRP-1…R-VLD-GRP-2иR-VLD-GRP-X1…R-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-флагом переключения required | R-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-схеме.