Pydantic — граница доверия сервиса. На ней входные данные превращаются из «какого-то JSON» в типизированный объект с гарантиями: если объект построился, значит, поля на месте и нужного типа. FastAPI встраивает Pydantic в каждый эндпоинт, поэтому понимать его — значит понимать, что именно проверяется и что отдаётся наружу.

Важная оговорка: здесь речь о Pydantic v2. У второй версии другой API, чем у первой, и смешивать их нельзя. Старые @validator и class Config — это v1; ниже только v2.

Модель и типы

Модель — класс-наследник BaseModel. Типы полей — обычные аннотации Python; Field добавляет ограничения и метаданные.

from pydantic import BaseModel, Field


class CreateProductRequest(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    price: int = Field(gt=0)
    category: str

Этого уже достаточно: FastAPI разберёт тело запроса в CreateProductRequest, проверит длину имени и положительность цены, а при нарушении вернёт 422 с описанием, какое поле не прошло, — не доводя дело до твоего кода.

Валидаторы

Когда правило сложнее ограничения на одно поле, есть валидаторы. field_validator проверяет и нормализует отдельное поле; model_validator — связи между полями.

from pydantic import BaseModel, field_validator, model_validator


class CreateProductRequest(BaseModel):
    name: str
    price: int
    discount_price: int | None = None

    @field_validator("name")
    @classmethod
    def strip_name(cls, value: str) -> str:
        return value.strip()

    @model_validator(mode="after")
    def check_discount(self):
        if self.discount_price is not None and self.discount_price >= self.price:
            raise ValueError("discount_price must be lower than price")
        return self

field_validator помечается @classmethod и получает значение; model_validator(mode="after") работает с уже собранной моделью и видит все поля сразу. Брошенный ValueError FastAPI превратит в понятную ошибку валидации.

Сериализация

Обратная сторона — превратить объект в данные для ответа. В v2 это model_dump() (в словарь) и model_dump_json() (в JSON); метод model_validate() строит модель из словаря. Старые .dict() и .json() остались от v1 — в новом коде их не используют.

product = ProductResponse(id=1, name="Кофемолка", price=4990)
product.model_dump()       # {"id": 1, "name": "Кофемолка", "price": 4990}

В эндпоинтах сериализацию обычно делает сам FastAPI через response_model — вручную model_dump нужен реже, в основном для логов или внешних вызовов.

Раздельные модели запроса и ответа

Соблазн — одна модель и на вход, и на выход, и для хранения. Это источник утечек: то клиент пришлёт поле, которое не должен задавать (id, created_at), то наружу уйдёт техническое поле. Поэтому модели разделяют по роли.

class CreateProductRequest(BaseModel):
    name: str
    price: int


class ProductResponse(BaseModel):
    id: int
    name: str
    price: int
    created_at: datetime

Запрос несёт только то, что клиент вправе задать; ответ — только то, что сервис готов показать; доменная модель внутри — третья, со своей логикой. Преобразование между ними — явный код (to_command, from_domain), а не общая на всех модель. Это та же дисциплина границ, что в роутинге: что входит и что выходит — разные контракты.

Где это в UCP

Pydantic-модели — это DTO на краю сервиса, не доменные объекты. Валидация формата (длина, диапазон, обязательность) — здесь, на границе; бизнес-правила (можно ли вообще создать такой продукт в текущем состоянии) — в Handler-е и домене, не в модели запроса. Смешивать их — значит размазывать правила по краю.

Так Pydantic закрывает форму данных, оставляя смысл — Handler-у. Это аналог Bean Validation в Spring-биндинге: граница проверяет формат, домен — правила. Чёткая граница на входе — то, что позволяет продукт-инженеру доверять данным внутри сервиса и не перепроверять их на каждом шаге.