Domain-Driven Design Моделирование типами: недопустимые состояния невыразимы
0%

Моделирование типами: недопустимые состояния невыразимы

Моделирование типами: недопустимые состояния невыразимы

В предыдущих статьях инварианты защищались тремя способами: проверкой внутри агрегата, юнит-тестом и договорённостью на ревью. Все три работают, и все три имеют общее свойство — они защищают в рантайме или не защищают вовсе. Проверка внутри агрегата срабатывает, когда кто-то уже собрал неверные данные; тест ловит только те случаи, которые вы придумали; договорённость держится ровно до прихода нового человека.

Есть четвёртый способ, который в 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 или бросает исключение. Знание о том, что данные корректны, после неё теряется: вызывающий код получает тот же тип, что и до проверки. Парсинг принимает широкий тип и возвращает узкий, само существование которого доказывает корректность.

@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. Типичные ошибки

  1. Обёртка ради обёртки. CityName, StreetName, HouseNumber в справочнике адресов, где нет ни одного правила. Заводите тип, когда у понятия есть правило или когда его можно перепутать с соседним.
  2. Парсинг внутри домена. Email.parse() вызывается в агрегате — значит, примитив всё-таки проник внутрь. Парсеры живут на границе, домен принимает готовые узкие типы.
  3. Сумма типов без проверки полноты. Без assert_never / never / строгого when вы получили ту же if-лапшу, только многословнее.
  4. Утечка формы типов в API. Публичный контракт повторяет варианты внутренней суммы типов — теперь любое изменение модели ломает потребителей.
  5. Типы вместо разговора. Изящная алгебра, выведенная из головы, — та же выдуманная модель, что и в обзоре трека, просто с более красивым синтаксисом.
  6. Any в домене. Одна аннотация Any в Python обесценивает проверку типов на всём пути ниже.
  7. Игнорирование базы. Типы запретили невозможное состояние в коде, а таблица по-прежнему разрешает. Через год из бэкапа приезжает строка, которую невозможно распарсить.

9. Мини-итог

  • Тип с n необязательными полями разрешает 2ⁿ состояний, домен допускает единицы. Разница — это гарантированные будущие баги.
  • Парси, а не валидируй: примитивы живут на границе контекста, домен принимает только узкие типы, само существование которых доказывает корректность.
  • Типизированные идентификаторы — самый дешёвый приём: вечер работы убирает целый класс ошибок, который иначе растёт как O(k²).
  • Сумма типов на состояния агрегата убирает опциональные поля и включает проверку полноты разбора; переходы становятся функциями между конкретными типами, и запрещённый переход просто не компилируется.
  • Платить придётся маппингом на базу (плюс обязательные CHECK-ограничения), сопротивлением ORM и порогом входа. Приёмы переносимы, а вот гарантии зависят от языка и от строгого режима в CI.
  • Типы не заменяют разговор с экспертом. Они лишь фиксируют то, о чём вы уже договорились.

Источники


Что дальше

Типы отлично выражают то, что верно сейчас. Но половина сложных доменов — страхование, биллинг, тарифы, договоры, кадры — про то, что было верно вчера, действует с первого числа и было исправлено задним числом на прошлой неделе. Это отдельный класс задач, где наивная модель ломается независимо от качества типов.

Время в домене: процессы, версии правил и темпоральные данные

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов