Моделирование типами: недопустимые состояния невыразимы
В предыдущих статьях инварианты защищались тремя способами: проверкой внутри агрегата, юнит-тестом и договорённостью на ревью. Все три работают, и все три имеют общее свойство — они защищают в рантайме или не защищают вовсе. Проверка внутри агрегата срабатывает, когда кто-то уже собрал неверные данные; тест ловит только те случаи, которые вы придумали; договорённость держится ровно до прихода нового человека.
Есть четвёртый способ, который в DDD-литературе на русском почти не описан, хотя в проде он окупается быстрее остальных: сделать неверное состояние невыразимым в типах. Тогда защищает компилятор или проверка типов в CI — на каждой строке кода, бесплатно и без исключений.
Статья про то, какую часть модели туда переносить, чем за это платят и как это выглядит в языках, которые реально используются на бэкенде: Python, TypeScript, Kotlin, C#, Rust, Go.
1. Проблема: сколько состояний разрешает ваш тип
Возьмём агрегат подписки из сквозного примера трека и посмотрим на него глазами системы типов.
Формально: запись с n необязательными полями допускает 2ⁿ комбинаций «заполнено / пусто», а домен
обычно допускает единицы. Разница — это пространство невыразимых, но выразимых состояний, и каждое
из них однажды случится: из-за бага в маппере, из-за миграции, из-за ретрая, из-за ручного UPDATE
в проде в три часа ночи.
# Тип разрешает 16 комбинаций. Домен допускает 4.
@dataclass
class Subscription:
status: str # ещё и строка: "actve" скомпилируется
trial_ends_at: date | None
paid_until: date | None
cancelled_at: date | None
grace_until: date | None
def is_billable(s: Subscription) -> bool:
# Каждая функция вынуждена заново доказывать, что данные согласованы.
if s.status == "active":
assert s.paid_until is not None, "активная подписка без оплаченного периода"
return s.cancelled_at is None
return False
Строка с assert — это и есть цена: знание о согласованности приходится доказывать заново в каждой
функции, потому что тип его не несёт. Умножьте на 40 функций, работающих с подпиской.
Полезная метрика для ревью модели: отношение «состояний, разрешённых типом» к «состояниям, допустимым
в домене». Для str-статуса и четырёх nullable-полей это 16 · (число возможных строк) к 4 — то есть
практически бесконечность. Цель — привести отношение к единице там, где это дёшево.
2. Парси, а не валидируй
Первый и самый переносимый приём (термин ввёл Алексис Кинг, «Parse, don’t validate»).
Валидация принимает данные и возвращает bool или бросает исключение. Знание о том, что данные
корректны, после неё теряется: вызывающий код получает тот же тип, что и до проверки.
Парсинг принимает широкий тип и возвращает узкий, само существование которого доказывает корректность.
Email.parse, Money.parse,
Quantity.parse"] P -->|ошибка| E["Список нарушений
-> 422 клиенту"] end subgraph CORE["Домен: только узкие типы"] P -->|успех| D["Email, Money, Quantity"] D --> AGG["Агрегат: инварианты
СВЯЗЕЙ между значениями"] end style CORE stroke:#2f9e6e,stroke-width:2px style EDGE stroke:#c9a227,stroke-width:2px
@dataclass(frozen=True)
class Email:
"""Существование объекта Email доказывает, что строка прошла проверку.
Ни одна функция ниже по стеку не обязана проверять это повторно."""
value: str
@classmethod
def parse(cls, raw: str) -> "Email | InvalidEmail":
candidate = raw.strip().lower()
if "@" not in candidate or candidate.startswith("@"):
return InvalidEmail(raw, "нет локальной части или символа @")
return cls(candidate)
@dataclass(frozen=True)
class Quantity:
"""Положительное целое. Нигде дальше не нужен `if qty <= 0`."""
value: int
def __post_init__(self) -> None:
if self.value <= 0:
raise ValueError("количество должно быть положительным")
Дисциплина, которая из этого следует, ровно одна и она жёсткая:
Примитивы (
str,int,dict) живут только на границе контекста. Внутрь домена они не проходят. Функция домена, принимающаяstr, — это функция, которая обязана валидировать, и однажды забудет.
Практический эффект заметен на количестве проверок: в типичном сервисе после перевода на парсинг число
проверок «а не пустое ли, а не отрицательное ли» падает в разы, потому что все они собираются в одном
слое — там же, где формируется ответ 422 с понятным списком ошибок.
3. Идентификаторы: самая дешёвая победа
Самая частая и самая дурацкая ошибка в системах с несколькими сущностями — передать не тот идентификатор.
str в роли order_id, customer_id и invoice_id компилируется всегда.
from typing import NewType
OrderId = NewType("OrderId", str)
CustomerId = NewType("CustomerId", str)
def cancel_order(order_id: OrderId, by: CustomerId) -> None: ...
cancel_order(customer_id, order_id) # mypy: аргументы перепутаны — ошибка типа
NewType в Python не создаёт объект в рантайме (ноль накладных расходов) и виден только проверке типов.
В других языках — свои механизмы, и стоят они по-разному:
| Язык | Механизм | Рантайм-стоимость | Комментарий |
|---|---|---|---|
| Python | NewType, dataclass(frozen=True, slots=True) |
0 / один объект | NewType бесплатен, но и защищает только mypy/pyright |
| TypeScript | branded types: type OrderId = string & { __brand: "OrderId" } |
0 | Только на этапе компиляции; в JS-рантайме это строка |
| Kotlin | @JvmInline value class OrderId(val v: String) |
0 в большинстве случаев | Инлайнится в примитив, кроме дженериков и nullable |
| C# | readonly record struct OrderId(string Value) |
0 аллокаций | Плюс бесплатные Equals/GetHashCode |
| Rust | struct OrderId(String); (newtype) |
0 | Гарантия компилятора, не соглашение |
| Java | record OrderId(String value) |
одна аллокация | До Valhalla — объект в куче; обычно незаметно |
| Go | type OrderId string |
0 | Защищает от неявного смешения, но OrderId(s) конвертирует явно |
Оценка выгоды: если в системе k типов идентификаторов, число мест, где их можно перепутать, растёт как
O(k²) по парам, и ни один тест не покрывает эту матрицу. Типизация превращает весь класс ошибок в
ошибку сборки за один вечер работы. Это самый выгодный по соотношению «усилие/эффект» приём во всей статье.
4. Суммы типов для состояний агрегата
Второй по значимости приём — заменить пару «enum статуса + набор nullable-полей» на сумму типов (sum type, discriminated union, sealed class): по одному варианту на состояние, и в каждом варианте только те поля, которые в этом состоянии осмысленны.
Python 3.10+ выражает это союзом датаклассов и match:
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Trial:
plan: "PlanCode"; ends_at: date
@dataclass(frozen=True)
class Active:
plan: "PlanCode"; paid_until: date; seats: int
@dataclass(frozen=True)
class PastDue:
plan: "PlanCode"; paid_until: date; grace_until: date; failed_attempts: int
@dataclass(frozen=True)
class Expired:
expired_at: date; reason: str
Subscription = Trial | Active | PastDue | Expired
def is_billable(s: Subscription) -> bool:
"""Ни одной проверки на None: поля есть только там, где имеют смысл."""
match s:
case Trial():
return False
case Active() | PastDue():
return True
case Expired():
return False
# mypy с включённым exhaustiveness-check сообщит, если добавят пятый вариант
# и забудут дописать ветку сюда: assert_never(s)
TypeScript даёт то же самое размеченным объединением и получает проверку полноты бесплатно:
type Subscription =
| { kind: "trial"; plan: PlanCode; endsAt: Date }
| { kind: "active"; plan: PlanCode; paidUntil: Date; seats: number }
| { kind: "pastDue"; plan: PlanCode; paidUntil: Date; graceUntil: Date }
| { kind: "expired"; expiredAt: Date; reason: ExpiryReason };
function isBillable(s: Subscription): boolean {
switch (s.kind) {
case "trial": return false;
case "active": return true;
case "pastDue": return true;
case "expired": return false;
default: {
// Если появится пятый вариант, компилятор упадёт ИМЕННО ЗДЕСЬ,
// а не в проде через неделю.
const never: never = s;
return never;
}
}
}
Что реально меняется, помимо эстетики:
- Проверка полноты. Добавили состояние — компилятор перечислил все места, где его забыли учесть. Никакой другой механизм этого не даёт: ни тесты, ни ревью, ни линтер.
- Поля перестали быть опциональными.
paid_untilвActive— неdate | None, аdate. Исчезает целый класс проверок и целый классNoneType has no attributeв проде. - Переходы стали явными. См. следующий раздел.
- Модель читается как список из речи эксперта. «Подписка бывает пробная, активная, просроченная или истёкшая» — ровно четыре строки кода, ровно одна фраза продакта.
5. Переходы состояний как типизированные функции
Логичное продолжение: если состояния — разные типы, то переход — это функция из одного конкретного типа в другой конкретный тип. Тогда «отменить истёкшую подписку» перестаёт быть ошибкой рантайма и становится ошибкой сборки.
def payment_failed(s: Active, at: date, grace_days: int = 7) -> PastDue:
"""Сигнатура — это правило домена: просрочка бывает только у активной подписки."""
return PastDue(plan=s.plan, paid_until=s.paid_until,
grace_until=at + timedelta(days=grace_days), failed_attempts=1)
def payment_received(s: PastDue, period_end: date) -> Active:
"""Обратный переход существует только из PastDue."""
return Active(plan=s.plan, paid_until=period_end, seats=0)
# А вот такой функции нет и не будет — правило выражено ОТСУТСТВИЕМ кода:
# def payment_received(s: Expired, ...) -> Active
Тот же приём в терминах Скотта Влашина — workflow as function: каждый шаг бизнес-процесса это функция с типом, в котором записано предусловие и постусловие.
# Полный процесс оформления: типы описывают конвейер лучше любой документации
def validate(cmd: UnvalidatedOrder) -> Result[ValidatedOrder, list[ValidationError]]: ...
def price(order: ValidatedOrder, tariff: Tariff) -> PricedOrder: ...
def reserve(order: PricedOrder, stock: StockPort) -> Result[ReservedOrder, OutOfStock]: ...
def confirm(order: ReservedOrder) -> tuple[ConfirmedOrder, OrderConfirmed]: ...
Прочитайте четыре сигнатуры подряд: это и есть спецификация процесса. Невозможно зарезервировать
непроценённый заказ, невозможно подтвердить незарезервированный — не потому, что «мы договорились»,
а потому, что типы не стыкуются. Подробнее про Result и работу с ошибками как со значениями —
в треке функционального программирования.
Практическая оговорка про Python: Result здесь — не догма. В Python идиоматичны исключения, и
доменное исключение с понятным именем часто читается лучше, чем цепочка Result. Разумная граница:
ожидаемые исходы бизнес-процесса (товара нет, лимит исчерпан) — значения; нарушение инварианта
(попытка отменить истёкшую) — исключение, потому что это баг вызывающего кода.
6. Цена: где типы начинают мешать
Честный разговор, без которого статья была бы рекламой.
Маппинг на реляционную базу. Сумма типов не отображается на таблицу естественным образом. Три рабочих варианта:
| Вариант | Плюсы | Минусы | Когда брать |
|---|---|---|---|
| Одна таблица + дискриминатор | Простые запросы, один SELECT |
Нужны CHECK-ограничения на согласованность NULL |
По умолчанию: вариантов мало, поля пересекаются |
Одна таблица + jsonb состояния |
Схема не меняется при новом варианте | Нельзя нормально индексировать и джойнить по полям состояния | Вариантов много, читают только по id |
| Корень + таблица на вариант | Строгая схема, никаких лишних NULL |
Джойны, миграции при переходах | Варианты сильно разные и живут долго |
Ограничение CHECK для первого варианта — обязательная часть, иначе типы защищают только код, а база
по-прежнему разрешает мусор:
ALTER TABLE subscription ADD CONSTRAINT subscription_state_shape CHECK (
(kind = 'trial' AND trial_ends_at IS NOT NULL AND paid_until IS NULL)
OR (kind = 'active' AND paid_until IS NOT NULL AND grace_until IS NULL)
OR (kind = 'pastDue' AND paid_until IS NOT NULL AND grace_until IS NOT NULL)
OR (kind = 'expired' AND expired_at IS NOT NULL)
);
Прочие издержки, о которых стоит знать заранее:
- ORM сопротивляется. SQLAlchemy, Hibernate и EF умеют single-table inheritance, но код маппера становится заметно менее очевидным. Ручной маппер (см. статью про персистентность) здесь часто дешевле.
- Сериализация наружу. В API и в события уезжает не ваша сумма типов, а плоский контракт: публичный формат должен быть скучным и толерантным, см. доменные события.
- Порог входа. Разработчик, впервые видящий
matchпо вариантам, тратит день. Разработчик, впервые видящий 12 nullable-полей, тратит квартал — но не замечает этого. - Не всё стоит типизировать. Пятнадцать типов-обёрток вокруг
strв CRUD-справочнике — карго-культ ровно того же сорта, что и агрегаты вокруг таблицы городов.
7. Что умеет ваш язык
| Возможность | Python 3.12 | TypeScript | Kotlin | C# 12 | Rust | Go 1.22 |
|---|---|---|---|---|---|---|
| Суммы типов | союзы + match (проверка в mypy) |
размеченные объединения | sealed interface |
abstract record + иерархия |
enum |
нет, только интерфейсы |
| Проверка полноты разбора | assert_never в mypy/pyright |
да, через never |
да, в when по sealed |
частично (switch по типам) |
да, обязательна | нет |
| Бесплатные обёртки | NewType (только для типчекера) |
branded types | value class |
record struct |
newtype | type X string |
| Неизменяемость по умолчанию | нет (frozen=True вручную) |
нет (readonly) |
val |
record |
да | нет |
| Ошибки как значения | вручную (Result-библиотеки) |
вручную | Result в stdlib |
вручную | Result в языке |
error идиоматичен |
| Проверка в CI | mypy / pyright | tsc | компилятор | компилятор | компилятор | компилятор |
Вывод, который из таблицы следует: приёмы переносимы, гарантии — нет. В Rust и Kotlin невыразимое
действительно невыразимо. В Python и TypeScript защита существует ровно до тех пор, пока в CI запущен
mypy --strict или tsc --strict и никто не пишет Any / as any. Поэтому первый шаг при переходе на
такой стиль в Python — не рефакторинг модели, а строгий режим проверки типов на доменном пакете:
# pyproject.toml — строгость там, где она окупается, и мягкость на границе
[[tool.mypy.overrides]]
module = "billing.domain.*"
strict = true
disallow_any_explicit = true
warn_return_any = true
Go в этой таблице выглядит бедно, и это не придирка: в Go суммы типов не выражаются, и вместо них
используют интерфейс с приватным методом-маркером плюс switch по типу — без проверки полноты.
Это осознанный компромисс языка, и в Go-проектах защиту инвариантов приходится оставлять
на конструкторах и тестах. Подробнее про идиоматику — в
треке Go.
8. Типичные ошибки
- Обёртка ради обёртки.
CityName,StreetName,HouseNumberв справочнике адресов, где нет ни одного правила. Заводите тип, когда у понятия есть правило или когда его можно перепутать с соседним. - Парсинг внутри домена.
Email.parse()вызывается в агрегате — значит, примитив всё-таки проник внутрь. Парсеры живут на границе, домен принимает готовые узкие типы. - Сумма типов без проверки полноты. Без
assert_never/never/ строгогоwhenвы получили ту жеif-лапшу, только многословнее. - Утечка формы типов в API. Публичный контракт повторяет варианты внутренней суммы типов — теперь любое изменение модели ломает потребителей.
- Типы вместо разговора. Изящная алгебра, выведенная из головы, — та же выдуманная модель, что и в обзоре трека, просто с более красивым синтаксисом.
Anyв домене. Одна аннотацияAnyв Python обесценивает проверку типов на всём пути ниже.- Игнорирование базы. Типы запретили невозможное состояние в коде, а таблица по-прежнему разрешает. Через год из бэкапа приезжает строка, которую невозможно распарсить.
9. Мини-итог
- Тип с
nнеобязательными полями разрешает 2ⁿ состояний, домен допускает единицы. Разница — это гарантированные будущие баги. - Парси, а не валидируй: примитивы живут на границе контекста, домен принимает только узкие типы, само существование которых доказывает корректность.
- Типизированные идентификаторы — самый дешёвый приём: вечер работы убирает целый класс ошибок,
который иначе растёт как
O(k²). - Сумма типов на состояния агрегата убирает опциональные поля и включает проверку полноты разбора; переходы становятся функциями между конкретными типами, и запрещённый переход просто не компилируется.
- Платить придётся маппингом на базу (плюс обязательные
CHECK-ограничения), сопротивлением ORM и порогом входа. Приёмы переносимы, а вот гарантии зависят от языка и от строгого режима в CI. - Типы не заменяют разговор с экспертом. Они лишь фиксируют то, о чём вы уже договорились.
Источники
- Scott Wlaschin. Domain Modeling Made Functional — главная книга по теме; бесплатная серия статей: F# for Fun and Profit — Designing with Types.
- Alexis King. Parse, don’t validate — первоисточник термина и лучшая аргументация.
- Yaron Minsky. Effective ML: make illegal states unrepresentable — откуда пошёл сам принцип.
- Документация: Python
typing.NewTypeиassert_never, TypeScript Discriminated Unions, Kotlin sealed classes, Rust enums. - Vladimir Khorikov. Always-Valid Domain Model — спор о том, где именно должна жить валидация; полезно как контраргумент.
- Соседние треки портала: алгебраические типы и сопоставление с образцом, обработка ошибок, типизация в Python.
Что дальше
Типы отлично выражают то, что верно сейчас. Но половина сложных доменов — страхование, биллинг, тарифы, договоры, кадры — про то, что было верно вчера, действует с первого числа и было исправлено задним числом на прошлой неделе. Это отдельный класс задач, где наивная модель ломается независимо от качества типов.
Время в домене: процессы, версии правил и темпоральные данные