Опирается на правила: R-VLD-XF-1R-VLD-XF-2 и R-VLD-XF-X1R-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_rangestart_date, end_date
password == password_confirm_passwords_matchpassword, password_confirm
amount <= credit_limit_amount_within_limitamount, credit_limit
Хотя бы email или phone_at_least_one_contactemail, phone
valid_from < valid_until_validity_periodvalid_from, valid_until

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

АнтипаттернПравилоЧто взамен
Cross-field проверка в Handler или endpoint-функцииR-VLD-XF-X2@model_validator(mode="after") на DTO
Одноразовый @model_validator вместо mixin при 3+ DTOR-VLD-XF-X1Mixin-класс в backend/validation/mixins.py
Имя метода _validate, _checkR-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 — другой механизм, решающий другую задачу.