Опирается на правила:
R-VLD-XF-1…R-VLD-XF-2иR-VLD-XF-X1…R-VLD-XF-X2из Validation Style Guide → раздел 5. Cross-field validation.
Важно знать
- Cross-field = правило, в котором участвуют 2+ поля одного объекта:
start_date <= end_date,password == password_confirm,amount <= credit_limit.- Реализуется через
@model_validator(mode="after")— метод получаетselfс уже распарсенными полями.- Имя метода описывает правило, не объект:
_date_range,_passwords_match. Не_validateили_check.- Для частых правил — mixin-класс (
DateRangeMixin), подмешиваемый к любому DTO сstart_date/end_date.- Cross-field валидация в Handler перед dispatch — антипаттерн. Это контракт DTO, не бизнес-правило.
mode="after"означает «поля уже провалидированы по типам»;mode="before"— сырые данные до парсинга, редкий кейс.
Cross-field — узкая категория, где стандартных Field(...) недостаточно: нужно сравнить значения двух полей. @model_validator(mode="after") — единственный идиоматичный способ держать такое правило читаемым и переиспользуемым. Раскрытие раздела 5 гайда.
@model_validator(mode="after"): структура
R-VLD-XF-1: метод навешивается на модель, получает self с уже заполненными полями.
from datetime import date
from pydantic import BaseModel, Field, model_validator
class OrderFilterRequest(BaseModel):
start_date: date
end_date: date
customer_name: str | None = Field(None, max_length=100)
@model_validator(mode="after")
def _date_range(self) -> "OrderFilterRequest":
if self.end_date < self.start_date:
raise ValueError("end_date не может быть раньше start_date")
return self
Тонкости:
mode="after"— поля уже распарсены;self.start_dateгарантированноdate, не строка.- Метод должен вернуть
self— Pydantic использует возвращаемое значение как результат модели. - Если поле optional (
end_date: date | None = None), проверяем наNoneперед сравнением:
@model_validator(mode="after")
def _date_range(self) -> "OrderFilterRequest":
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValueError("end_date не может быть раньше start_date")
return self
Переиспользование через mixin
R-VLD-XF-X1: если правило встречается в нескольких DTO (например, start_date/end_date в фильтрах заказов, контрактов, платежей) — выносим в mixin-класс.
# backend/validation/mixins.py
from datetime import date
from pydantic import model_validator
class DateRangeMixin:
start_date: date
end_date: date
@model_validator(mode="after")
def _date_range(self) -> "DateRangeMixin":
if self.end_date < self.start_date:
raise ValueError("end_date не может быть раньше start_date")
return self
# backend/order/api/request.py
from backend.validation.mixins import DateRangeMixin
from pydantic import BaseModel
class OrderFilterRequest(DateRangeMixin, BaseModel):
start_date: date
end_date: date
status: str | None = None
# backend/contract/api/request.py
class ContractFilterRequest(DateRangeMixin, BaseModel):
start_date: date
end_date: date
organization_id: UUID | None = None
Один mixin — одно переиспользуемое правило. Добавление нового DTO с диапазоном дат — наследование, не копипаст.
Аналогично для паролей:
# backend/validation/mixins.py
class PasswordsMatchMixin:
password: str
password_confirm: str
@model_validator(mode="after")
def _passwords_match(self) -> "PasswordsMatchMixin":
if self.password != self.password_confirm:
raise ValueError("Пароли не совпадают")
return self
Именование метода
R-VLD-XF-2: имя метода — глагол + правило, не _validate и не _check.
# ХОРОШО
@model_validator(mode="after")
def _date_range(self) -> ...:
@model_validator(mode="after")
def _passwords_match(self) -> ...:
@model_validator(mode="after")
def _amount_within_limit(self) -> ...:
# ПЛОХО
@model_validator(mode="after")
def _validate(self) -> ...: # что именно?
@model_validator(mode="after")
def _check_fields(self) -> ...: # все поля?
Правильное имя делает модель читаемой: @model_validator ... def _date_range — понятно без комментария.
Несколько @model_validator на одной модели
Pydantic v2 позволяет несколько @model_validator методов. Каждый — отдельное правило с отдельным именем:
class CreateSberTransactionRequest(BaseModel):
amount: Decimal = Field(gt=0, decimal_places=2)
credit_limit: Decimal = Field(ge=0, decimal_places=2)
scheduled_at: datetime | None = None
expires_at: datetime | None = None
@model_validator(mode="after")
def _amount_within_limit(self) -> "CreateSberTransactionRequest":
if self.amount > self.credit_limit:
raise ValueError("Сумма превышает кредитный лимит")
return self
@model_validator(mode="after")
def _schedule_before_expiry(self) -> "CreateSberTransactionRequest":
if self.scheduled_at and self.expires_at:
if self.scheduled_at >= self.expires_at:
raise ValueError("Дата исполнения должна быть раньше даты истечения")
return self
Два независимых правила — два отдельных метода. Не один метод _validate с цепочкой if.
Cross-field в Handler — нет
R-VLD-XF-X2: проверка взаимоотношения полей в Handler перед handler.handle(cmd) — антипаттерн.
# ПЛОХО — cross-field в endpoint-функции или Handler
@router.post("/v1/orders/filter")
async def filter_orders(req: OrderFilterRequest, ...):
if req.end_date < req.start_date: # это контракт DTO, не бизнес-правило
raise HTTPException(400, "...")
...
# ХОРОШО — правило в модели
class OrderFilterRequest(BaseModel):
start_date: date
end_date: date
@model_validator(mode="after")
def _date_range(self) -> "OrderFilterRequest":
if self.end_date < self.start_date:
raise ValueError("end_date не может быть раньше start_date")
return self
@router.post("/v1/orders/filter")
async def filter_orders(req: OrderFilterRequest, ...):
...
Почему плохо в Handler:
- Это контракт входного DTO, а не бизнес-правило. Место — на модели.
HTTPException(400, ...)из Handler теряет структуруviolations. Клиент не видит, какое поле нарушено.- При добавлении второго эндпоинта с тем же DTO — правило придётся копировать.
Типичные cross-field правила
| Правило | Имя метода | Поля |
|---|---|---|
start_date <= end_date | _date_range | start_date, end_date |
password == password_confirm | _passwords_match | password, password_confirm |
amount <= credit_limit | _amount_within_limit | amount, credit_limit |
Хотя бы email или phone | _at_least_one_contact | email, phone |
valid_from < valid_until | _validity_period | valid_from, valid_until |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Cross-field проверка в Handler или endpoint-функции | R-VLD-XF-X2 | @model_validator(mode="after") на DTO |
Одноразовый @model_validator вместо mixin при 3+ DTO | R-VLD-XF-X1 | Mixin-класс в backend/validation/mixins.py |
Имя метода _validate, _check | R-VLD-XF-2 | Имя по правилу: _date_range, _passwords_match |
mode="before" для cross-field сравнений | — | mode="after" — поля уже распарсены |
Куда дальше
- Validation → раздел 5. Cross-field validation — нормативные формулировки
R-VLD-XF-*. - python/custom-constraints —
AfterValidatorдля правил уровня поля. - python/where-to-validate — почему cross-field на DTO, не в Handler.
- python/validation-groups — другой механизм, решающий другую задачу.