Опирается на правила:
R-VLD-WHERE-1…R-VLD-WHERE-4иR-VLD-WHERE-X1…R-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)
Как работает:
- FastAPI видит
req: CreateOrderRequestи вызывает Pydantic-валидацию до тела функции. - При нарушениях бросается
RequestValidationError. exception_handlerсмапит его в 400 problem+json с массивомviolations(cross-refR-ERR-MAP-2).- Клиент получает: какое поле, какое правило нарушено, на каком уровне вложенности.
Дополнительные правила:
- 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.getenv→None.- Сервис стартует «успешно».
- Первый запрос к базе падает с непонятной ошибкой через сотни строк стека.
С BaseSettings:
- При старте
Settings()выполняет Pydantic-валидацию. - Отсутствующий
database_url→ValidationErrorс точным указанием поля. - Сервис не стартует. 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-X1 | Field(ge=0) в Pydantic-модели эндпоинта |
| Pydantic-валидация на UseCase-команде | R-VLD-WHERE-X2 | Команда — @dataclass, валидируется один раз на edge |
os.environ["X"] для required-конфига | R-VLD-WHERE-X3 | BaseSettings с типизированным полем |
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-модель как источник правды контракта.