Опирается на правила: R-VLD-WHERE-1R-VLD-WHERE-4 и R-VLD-WHERE-X1R-VLD-WHERE-X4 из Validation Style Guide → раздел 1. Где валидируем.

Важно знать

  • В UCP-сервисе на FastAPI валидация живёт в трёх местах: эндпоинт (входной HTTP DTO — Pydantic-модель), BaseSettings (конфиг), агрегат (доменные инварианты). Других не должно быть.
  • Эндпоинт: async def create_order(req: CreateOrderRequest, ...). FastAPI сам бросит RequestValidationError, handler ProblemDetails смапит в 400 с массивом violations.
  • Конфиг: pydantic-settings BaseSettings. Невалидный конфиг → ValidationError на старте, не «сервис поднялся с битыми значениями».
  • Домен: raise DomainError(...) в методах агрегата. Не Field(ge=0) на полях доменного класса.
  • Nested Pydantic-модели валидируются рекурсивно автоматически — дополнительных директив не нужно.
  • Handler не валидирует. К моменту входа в Handler команда уже чистая.
  • Ручная if req.amount < 0: raise ... в Handler — антипаттерн. Теряется единый формат violations.

Валидация в FastAPI-проекте часто расползается по Handler-ам и вспомогательным функциям. В UCP-стиле — три явных места, у каждого свой инструмент и свой формат ответа. Раскрытие раздела 1 гайда.

Место 1: эндпоинт — Pydantic-модель в сигнатуре

R-VLD-WHERE-1: входной HTTP DTO — Pydantic-модель в параметре функции эндпоинта.

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

class OrderItemRequest(BaseModel):
    product_id: UUID
    quantity: int = Field(ge=1, le=9999)
    unit_price: Decimal = Field(gt=0, decimal_places=2)

class CreateOrderRequest(BaseModel):
    customer_id: UUID
    items: list[OrderItemRequest] = Field(min_length=1)

@router.post("/v1/orders", status_code=201)
async def create_order(
    req: CreateOrderRequest,
    handler: Annotated[CreateOrderHandler, Depends()],
) -> OrderResponse:
    cmd = map_to_command(req)
    result = await handler.handle(cmd)
    return map_to_response(result)

Как работает:

  1. FastAPI видит req: CreateOrderRequest и вызывает Pydantic-валидацию до тела функции.
  2. При нарушениях бросается RequestValidationError.
  3. exception_handler смапит его в 400 problem+json с массивом violations (cross-ref R-ERR-MAP-2).
  4. Клиент получает: какое поле, какое правило нарушено, на каком уровне вложенности.

Дополнительные правила:

  • Nested-модели (R-VLD-WHERE-4) валидируются рекурсивно автоматически — OrderItemRequest внутри items проверяется без дополнительных директив.
  • Query/path параметры@app.get(...) с Query(ge=0), Path(min_length=1) прямо в сигнатуре; FastAPI валидирует их аналогично.

Место 2: конфиг — BaseSettings

R-VLD-WHERE-2: невалидный конфиг должен ломать старт, не первый запрос.

from pydantic import PostgresDsn, AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from datetime import timedelta

class PaymentSettings(BaseSettings):
    api_url: AnyHttpUrl
    api_key: str
    timeout: timedelta = timedelta(seconds=5)

class Settings(BaseSettings):
    database_url: PostgresDsn
    payment: PaymentSettings
    debug: bool = False
    model_config = SettingsConfigDict(env_prefix="APP_", env_nested_delimiter="__")

settings = Settings()

Что происходит без BaseSettings (например, при чтении через os.environ):

  • Опечатка APP_DATBASE_URL вместо APP_DATABASE_URL.
  • os.environ["APP_DATABASE_URL"]KeyError, но после перехвата в os.getenvNone.
  • Сервис стартует «успешно».
  • Первый запрос к базе падает с непонятной ошибкой через сотни строк стека.

С BaseSettings:

  • При старте Settings() выполняет Pydantic-валидацию.
  • Отсутствующий database_urlValidationError с точным указанием поля.
  • Сервис не стартует. Healthcheck сразу красный.

Вложенные PaymentSettings валидируются рекурсивно — это и есть R-VLD-CFG-4 в Python.

Место 3: домен — исключение в методе агрегата

R-VLD-WHERE-3: доменные инварианты — не Pydantic. Агрегат проверяет состояние внутри методов и бросает доменное исключение.

from dataclasses import dataclass, field
from enum import Enum

class OrderStatus(Enum):
    CREATED = "CREATED"
    CONFIRMED = "CONFIRMED"
    SHIPPED = "SHIPPED"

@dataclass
class Order:
    id: OrderId
    status: OrderStatus
    items: list[OrderItem]

    def confirm(self) -> None:
        if self.status != OrderStatus.CREATED:
            raise OrderAlreadyConfirmedError(self.id, self.status)
        if not self.items:
            raise EmptyOrderError(self.id)
        self.status = OrderStatus.CONFIRMED

Почему не Pydantic на агрегате:

  • Field(ge=0) на поле Order — Pydantic-валидация срабатывает только при конструировании. Состояние, изменённое бизнес-методом, не переваливдируется.
  • BaseModel в домене — утечка инфраструктурного фреймворка в domain layer.
  • Доменное исключение несёт бизнес-контекст (OrderId, текущий status); Pydantic — только текст.

Handler не валидирует

R-VLD-WHERE-X1: в Handler-е никакой входной валидации нет. Команда пришла из маппера поверх уже валидного DTO.

# ПЛОХО — Handler с ручной валидацией
class CreateOrderHandler:
    async def handle(self, cmd: CreateOrder) -> OrderId:
        if cmd.customer_id is None:
            raise ValueError("customer_id required")
        if any(i.quantity <= 0 for i in cmd.items):
            raise ValueError("quantity must be positive")
        ...

# ХОРОШО — Handler доверяет валидации на edge
class CreateOrderHandler:
    def __init__(
        self,
        customer_repo: CustomerRepository,
        order_repo: OrderRepository,
    ) -> None:
        self._customers = customer_repo
        self._orders = order_repo

    async def handle(self, cmd: CreateOrder) -> OrderId:
        customer = await self._customers.find_by_id(cmd.customer_id)
        if customer is None:
            raise CustomerNotFoundError(cmd.customer_id)
        order = Order.create(customer, cmd.items)
        await self._orders.save(order)
        return order.id

Дублирование валидации на UseCase-команде — лишнее

R-VLD-WHERE-X2: UseCase-команда — @dataclass или NamedTuple, внутренний объект без Pydantic.

# ПЛОХО — Pydantic на команде
class CreateOrder(BaseModel):
    customer_id: UUID     # дубль из CreateOrderRequest
    items: list[...]

# ХОРОШО — чистый dataclass
@dataclass(frozen=True)
class CreateOrder:
    customer_id: UUID
    items: list[CreateOrderItem]

Команда строится mapper-ом из валидного DTO. Pydantic на команде — двойная работа и нарушение принципа «команда = чистый домен без зависимостей на инфраструктурные фреймворки».

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

АнтипаттернПравилоЧто взамен
if cmd.amount < 0: raise ... в Handler для входной валидацииR-VLD-WHERE-X1Field(ge=0) в Pydantic-модели эндпоинта
Pydantic-валидация на UseCase-командеR-VLD-WHERE-X2Команда — @dataclass, валидируется один раз на edge
os.environ["X"] для required-конфигаR-VLD-WHERE-X3BaseSettings с типизированным полем
Field(...) на поле агрегата для доменного инвариантаR-VLD-WHERE-X4Проверка в методе агрегата + raise DomainError(...)

Куда дальше

  • Validation → раздел 1. Где валидируем — нормативные формулировки R-VLD-WHERE-*.
  • python/standard-constraints — Field(ge=), EmailStr, UUID и компания.
  • python/configuration-validation — BaseSettings подробно.
  • python/cross-field-validation — @model_validator(mode="after") для правил между полями.
  • python/openapi-generated-dto — code-first: Pydantic-модель как источник правды контракта.