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-биндинге: граница проверяет формат, домен — правила. Чёткая граница на входе — то, что позволяет продукт-инженеру доверять данным внутри сервиса и не перепроверять их на каждом шаге.