Domain-Driven Design DDD в коде: полный пример от домена до API
0%

DDD в коде: полный пример от домена до API

DDD в коде: полный пример от домена до API

Шесть предыдущих статей трека были про то, как думать: где границы (контексты), из чего собирать модель (тактические блоки), как её доставать из головы экспертов (Event Storming). Эта статья — про то, как всё это выглядит в репозитории, который можно склонировать и запустить.

Мы возьмём один контекст — управление подписками SaaS — и пройдём его насквозь: от объекта-значения Money до HTTP-эндпоинта и тестов. Не «пример ради примера»: у подписок настоящие, неприятные, взаимозависимые правила — пробный период, смена плана в середине оплаченного месяца, места (seats), просрочка платежа, отмена с догоранием до конца периода. Именно на таких правилах анемичная модель разваливается первой.

Читая, держите в голове главный вопрос статьи:

где живёт решение? Каждое «если» в коде должно иметь ровно один законный адрес, и по адресу должно быть понятно, кто за него отвечает.


1. Постановка: единый язык до единой строки кода

Выпишем правила так, как их формулирует продакт (это буквально результат сессии Event Storming):

  1. Подписка начинается с пробного периода на 14 дней. Пробный период даётся клиенту один раз — повторно trial не выдаётся даже после отмены.
  2. Пока подписка активна, клиент может сменить план в любой день. Деньги пересчитываются пропорционально остатку оплаченного периода; дата следующего списания не сдвигается.
  3. Число мест нельзя опустить ниже числа уже назначенных сотрудников и нельзя поднять выше лимита плана.
  4. Если списание не прошло — подписка переходит в просрочку и живёт так 7 дней (grace period), продолжая работать. Успешная оплата возвращает её в активные, иначе она истекает.
  5. Отмена не выключает подписку немедленно: она становится отменяемой и работает до конца оплаченного периода. До этого момента отмену можно отозвать.
  6. Истёкшая подписка — терминальное состояние. Новую подписку заводят заново (и уже без пробного периода).

Обратите внимание: в этих шести пунктах нет ни одного слова «таблица», «джоба», «эндпоинт». Это и есть единый язык, и почти каждое существительное/глагол оттуда появится в коде под тем же именем. Если в коде появится SubscriptionStateManagerService.process() — язык потерян, и вернуть его будет дороже, чем написать заново.

Структура проекта

billing/
├── domain/                     # ноль зависимостей от фреймворков
│   ├── money.py                # Money, Percent — объекты-значения
│   ├── period.py               # BillingPeriod
│   ├── plan.py                 # Plan, PlanCode — снимок из каталога
│   ├── subscription.py         # агрегат: корень + инварианты + события
│   ├── events.py               # доменные события
│   ├── errors.py               # доменные исключения
│   └── ports.py                # Protocol: репозиторий, UoW, часы, каталог
├── application/
│   ├── commands.py             # ChangePlan, AssignSeats, CancelSubscription
│   └── handlers.py             # сценарии: 5–15 строк каждый
├── infrastructure/
│   ├── schema.sql
│   ├── pg_subscription_repo.py # маппинг агрегата на таблицы + версия
│   ├── unit_of_work.py         # транзакция + слив событий в outbox
│   └── outbox_relay.py         # фоновая доставка в брокер
├── api/
│   ├── dto.py                  # pydantic-модели запроса/ответа
│   ├── routes.py               # FastAPI
│   └── error_mapping.py        # доменное исключение → HTTP-код
└── tests/
    ├── unit/                   # домен, без моков и без БД
    ├── integration/            # репозиторий против настоящего Postgres
    └── e2e/                    # HTTP-уровень

Правило импортов, которое проверяется линтером (см. раздел 10): domain не импортирует ничего из application, infrastructure, api. Это скучный однострочник в CI, который спасает архитектуру надёжнее, чем любые договорённости на ретро.

Путь одного HTTP-запроса через слои


2. Объекты-значения: фундамент, который нельзя пропустить

Соблазн начать с агрегата велик, но 60 % ошибок в биллинге — это ошибки на уровне денег и дат. Начинаем снизу.

# domain/money.py
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP

CENTS = Decimal("0.01")


class CurrencyMismatch(ValueError):
    """Складывать рубли с долларами — не ошибка ввода, а ошибка модели."""


@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self) -> None:
        # float здесь запрещён навсегда: 0.1 + 0.2 != 0.3, и в отчётности это всплывёт
        if not isinstance(self.amount, Decimal):
            raise TypeError("сумма должна быть Decimal, а не float/str")
        if len(self.currency) != 3 or not self.currency.isupper():
            raise ValueError("валюта — код ISO-4217 из трёх заглавных букв")
        if -self.amount.as_tuple().exponent > 2:
            raise ValueError("больше двух знаков после запятой: копейки уже потеряны")

    @classmethod
    def rub(cls, amount: str) -> "Money":
        return cls(Decimal(amount), "RUB")

    @classmethod
    def zero(cls, currency: str) -> "Money":
        return cls(Decimal("0.00"), currency)

    def _same_currency(self, other: "Money") -> None:
        if self.currency != other.currency:
            raise CurrencyMismatch(f"{self.currency} и {other.currency}")

    def __add__(self, other: "Money") -> "Money":
        self._same_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other: "Money") -> "Money":
        self._same_currency(other)
        return Money(self.amount - other.amount, self.currency)

    def prorate(self, used_days: int, total_days: int) -> "Money":
        """Доля суммы за used_days из total_days. Округление РОВНО ОДИН раз, в конце."""
        if total_days <= 0 or not (0 <= used_days <= total_days):
            raise ValueError("некорректная пропорция")
        exact = self.amount * used_days / total_days
        return Money(exact.quantize(CENTS, rounding=ROUND_HALF_UP), self.currency)

Три решения, которые стоит осознать:

  • frozen=True — значение неизменяемо, поэтому его безопасно передавать куда угодно и класть в set. slots=True убирает __dict__: на миллионах объектов это ощутимая экономия памяти.
  • Валидация в конструкторе. Невозможно создать Money с тремя знаками после запятой. Это сильнее любой проверки «на входе в API»: невалидное значение не существует в системе в принципе. Разница между валидацией DTO и инвариантом — тема, к которой мы вернёмся в разделе 9.
  • prorate округляет один раз. Наивная реализация (amount / total) * used теряет копейки на каждом делении; на 100 000 подписок это расхождение с бухгалтерией, которое ищут неделями.

Период — тоже значение, и он умеет считать сам себя:

# domain/period.py
import calendar
from dataclasses import dataclass
from datetime import date, timedelta


def add_one_month(d: date) -> date:
    """1 янв → 1 фев; 31 янв → 28 фев (клип на последний день месяца)."""
    year, month = (d.year + 1, 1) if d.month == 12 else (d.year, d.month + 1)
    return date(year, month, min(d.day, calendar.monthrange(year, month)[1]))


@dataclass(frozen=True, slots=True)
class BillingPeriod:
    start: date
    end: date  # включительно

    def __post_init__(self) -> None:
        if self.end < self.start:
            raise ValueError("период заканчивается раньше, чем начинается")

    @property
    def total_days(self) -> int:
        return (self.end - self.start).days + 1

    def remaining_days(self, on: date) -> int:
        """Сколько дней периода ещё не использовано, считая сам день `on`."""
        if not self.start <= on <= self.end:
            raise ValueError(f"{on} вне периода {self.start}..{self.end}")
        return (self.end - on).days + 1

    def next(self) -> "BillingPeriod":
        new_start = self.end + timedelta(days=1)
        return BillingPeriod(new_start, add_one_month(new_start) - timedelta(days=1))

    @classmethod
    def monthly_from(cls, start: date) -> "BillingPeriod":
        return cls(start, add_one_month(start) - timedelta(days=1))

add_one_month — ровно тот случай, где надо смотреть в глаза домену: 31 января + месяц = 28 февраля, и это бизнес-решение («клиент не теряет день»), а не техническая деталь. Именно поэтому оно лежит в domain/, а не в utils/date_helpers.py, где никто не найдёт его при споре с финансами.


3. Агрегат: состояния и инварианты

Жизненный цикл подписки из раздела 1 — это конечный автомат. Рисуем его до кода: диаграмма займёт пять минут, а спор «а можно ли сменить план в просрочке?» она закрывает навсегда.

Теперь состав агрегата. Он маленький — и это осознанно: границу агрегата задаёт транзакционный инвариант, а не «что логично сгруппировать» (подробный разбор — в статье 03).

Ключевая деталь диаграммы — пунктир к Plan. Каталог планов не входит в агрегат и агрегат его не загружает: снимок плана передаётся аргументом метода. Это единственный способ сохранить правило «агрегат не ходит в базу» и одновременно дать ему всё, что нужно для решения.

# domain/subscription.py
from __future__ import annotations

from datetime import date, timedelta
from enum import Enum
from typing import NewType
from uuid import UUID

from .errors import (InvalidTransition, SeatLimitExceeded, TrialAlreadyUsed,
                     SamePlan)
from .events import (PlanChanged, SubscriptionActivated, SubscriptionExpired,
                     CancellationRequested, DomainEvent)
from .money import Money
from .period import BillingPeriod
from .plan import Plan, PlanCode

SubscriptionId = NewType("SubscriptionId", UUID)
CustomerId = NewType("CustomerId", UUID)

TRIAL_DAYS = 14
GRACE_DAYS = 7


class Status(str, Enum):
    TRIALING = "trialing"
    ACTIVE = "active"
    PAST_DUE = "past_due"
    PENDING_CANCELLATION = "pending_cancellation"
    EXPIRED = "expired"


CHANGEABLE = {Status.TRIALING, Status.ACTIVE, Status.PENDING_CANCELLATION}


class Subscription:
    """Корень агрегата. Единственное место, где меняется состояние подписки."""

    def __init__(
        self,
        subscription_id: SubscriptionId, customer_id: CustomerId,
        plan_code: PlanCode, price: Money, period: BillingPeriod, status: Status,
        seats: int, max_seats: int, assigned_seats: int = 0,
        trial_used: bool = False, grace_until: date | None = None, version: int = 0,
    ) -> None:
        # приватность по соглашению: снаружи — только методы и свойства на чтение
        self._id, self._customer_id = subscription_id, customer_id
        self._plan_code, self._price = plan_code, price
        self._period, self._status, self._grace_until = period, status, grace_until
        self._seats, self._max_seats, self._assigned_seats = seats, max_seats, assigned_seats
        self._trial_used, self._version = trial_used, version
        self._events: list[DomainEvent] = []

    # ---------- фабрика ----------

    @classmethod
    def start_trial(
        cls, subscription_id: SubscriptionId, customer_id: CustomerId,
        plan: Plan, seats: int, today: date, customer_had_trial: bool,
    ) -> "Subscription":
        if customer_had_trial:
            raise TrialAlreadyUsed(f"клиент {customer_id} уже использовал пробный период")
        if not 1 <= seats <= plan.max_seats:
            raise SeatLimitExceeded(f"план {plan.code} допускает до {plan.max_seats} мест")
        return cls(
            subscription_id=subscription_id, customer_id=customer_id,
            plan_code=plan.code, price=plan.price,
            period=BillingPeriod.monthly_from(today), status=Status.TRIALING,
            seats=seats, max_seats=plan.max_seats, trial_used=True,
        )

    # ---------- чтение ----------
    # свойства нужны репозиторию для маппинга и сценарию для ответа;
    # сеттеров нет ни одного — состояние меняется только глаголами домена
    id = property(lambda self: self._id)
    status = property(lambda self: self._status)
    plan_code = property(lambda self: self._plan_code)
    price = property(lambda self: self._price)
    period = property(lambda self: self._period)
    seats = property(lambda self: self._seats)
    assigned_seats = property(lambda self: self._assigned_seats)
    max_seats = property(lambda self: self._max_seats)
    grace_until = property(lambda self: self._grace_until)
    version = property(lambda self: self._version)

    # ---------- поведение ----------

    def change_plan(self, new_plan: Plan, on: date) -> Money:
        """Меняет план и возвращает сумму к доплате (может быть отрицательной = кредит)."""
        if self._status not in CHANGEABLE:
            raise InvalidTransition(f"смена плана недоступна в статусе {self._status.value}")
        if new_plan.code == self._plan_code:
            raise SamePlan(f"подписка уже на плане {new_plan.code}")
        if self._assigned_seats > new_plan.max_seats:
            raise SeatLimitExceeded(
                f"назначено {self._assigned_seats} мест, план {new_plan.code} "
                f"допускает {new_plan.max_seats}: сначала освободите места"
            )

        remaining = self._period.remaining_days(on)
        total = self._period.total_days

        if self._status is Status.TRIALING:
            # в триале деньги ещё не списаны — пересчитывать нечего
            delta = Money.zero(new_plan.price.currency)
        else:
            credit = self._price.prorate(remaining, total)          # вернуть за старый план
            charge = new_plan.price.prorate(remaining, total)       # взять за новый
            delta = charge - credit

        old_code = self._plan_code
        self._plan_code = new_plan.code
        self._price = new_plan.price
        self._max_seats = new_plan.max_seats
        self._seats = min(self._seats, new_plan.max_seats)

        self._raise(PlanChanged(
            subscription_id=self._id, old_plan=old_code, new_plan=new_plan.code,
            delta=delta, effective_on=on,
        ))
        return delta

    def assign_seats(self, count: int) -> None:
        if count < 0:
            raise ValueError("отрицательное число мест")
        if count > self._seats:
            raise SeatLimitExceeded(f"оплачено {self._seats} мест, назначить пытаются {count}")
        self._assigned_seats = count

    def request_cancellation(self, on: date) -> None:
        if self._status not in {Status.ACTIVE, Status.TRIALING, Status.PAST_DUE}:
            raise InvalidTransition(f"нельзя отменить подписку в статусе {self._status.value}")
        self._status = Status.PENDING_CANCELLATION
        self._raise(CancellationRequested(
            subscription_id=self._id, requested_on=on, effective_on=self._period.end,
        ))

    def settle_payment(self, on: date) -> None:
        """Успешное списание: активирует триал или продлевает период."""
        if self._status is Status.EXPIRED:
            raise InvalidTransition("истёкшая подписка не продлевается — заводите новую")
        was_trial = self._status is Status.TRIALING
        self._status = Status.ACTIVE
        self._grace_until = None
        self._period = self._period.next()
        if was_trial:
            self._raise(SubscriptionActivated(subscription_id=self._id, activated_on=on))

    def mark_past_due(self, on: date) -> None:
        if self._status not in {Status.ACTIVE, Status.TRIALING}:
            raise InvalidTransition(f"просрочка неприменима к статусу {self._status.value}")
        self._status, self._grace_until = Status.PAST_DUE, on + timedelta(days=GRACE_DAYS)

    # resume() симметричен request_cancellation и опущен здесь ради краткости

    def expire(self, on: date) -> None:
        """Вызывается планировщиком: конец grace или конец периода при отмене."""
        if self._status is Status.EXPIRED:
            return  # идемпотентно: повторный вызов планировщика ничего не ломает
        allowed = (
            (self._status is Status.PAST_DUE and self._grace_until and on > self._grace_until)
            or (self._status is Status.PENDING_CANCELLATION and on > self._period.end)
            or (self._status is Status.TRIALING and on > self._period.end)
        )
        if not allowed:
            raise InvalidTransition("срок ещё не истёк — принудительное истечение запрещено")
        self._status = Status.EXPIRED
        self._raise(SubscriptionExpired(subscription_id=self._id, expired_on=on))

    # ---------- события ----------

    def _raise(self, event: DomainEvent) -> None:
        self._events.append(event)

    def pull_events(self) -> list[DomainEvent]:
        events, self._events = self._events, []
        return events

Что здесь сделано специально и почему это важно:

  • Ни одного публичного сеттера. Состояние меняется только через глаголы единого языка. Именно этим модель отличается от анемичной — про которую Фаулер написал Anemic Domain Model ещё в 2003-м, и с тех пор ничего не изменилось.
  • on: date передаётся снаружи. В домене нет date.today(). Иначе тест «что будет 31 декабря» превращается в подмену системного времени, а сценарий «пересчитать задним числом» становится невозможен.
  • Метод возвращает результат решения (delta: Money), а не пишет его в поле «на потом». Вызывающий получает ровно то, что ему нужно, и не лезет внутрь агрегата.
  • expire идемпотентен. Планировщик в проде запустится дважды — это не «если», а «когда».
  • version — счётчик для оптимистичной блокировки, единственная «инфраструктурная» уступка внутри домена. Альтернатива (хранить версию снаружи) даёт больше кода без выигрыша.

Пропорциональный перерасчёт при смене плана

Числа на схеме — это ровно тот случай, который надо превратить в тест. И он пишется в три строки, потому что домен ничего не знает про базу:

def test_upgrade_in_the_middle_of_period():
    sub = active_subscription(price=Money.rub("3000.00"),
                              period=BillingPeriod(date(2026, 11, 1), date(2026, 11, 30)))
    delta = sub.change_plan(plan("business", Money.rub("7500.00")), on=date(2026, 11, 12))
    assert delta == Money.rub("2850.00")   # 4750 − 1900

4. Порты: чего домен хочет от мира

Домен объявляет интерфейсы, инфраструктура их реализует — классический гексагональный подход Кокбёрна. В Python для этого достаточно Protocol: не нужно наследование, значит инфраструктура не импортируется даже ради базового класса.

# domain/ports.py
from datetime import date
from typing import Protocol

from .plan import Plan, PlanCode
from .subscription import Subscription, SubscriptionId


class SubscriptionRepository(Protocol):
    def get(self, subscription_id: SubscriptionId) -> Subscription:
        """Возвращает агрегат целиком или бросает SubscriptionNotFound."""

    def add(self, subscription: Subscription) -> None: ...

    def save(self, subscription: Subscription) -> None:
        """Сохраняет; бросает ConcurrencyConflict, если версия устарела."""


class PlanCatalog(Protocol):
    def get(self, code: PlanCode) -> Plan: ...


class Clock(Protocol):
    def today(self) -> date: ...


class UnitOfWork(Protocol):
    subscriptions: SubscriptionRepository

    def __enter__(self) -> "UnitOfWork": ...
    def __exit__(self, *exc) -> None: ...
    def commit(self) -> None: ...
    def rollback(self) -> None: ...

Обратите внимание: в порт репозитория не просачиваются Session, QuerySet, filter(...). Репозиторий — коллекция агрегатов, а не обёртка над ORM; подробности и антипаттерны — в статье 04.


5. Прикладной слой: сценарий на 12 строк

Прикладной слой — самый скучный код в проекте, и это признак здоровья. Если обработчик разросся до 80 строк с if-ами — почти всегда логика утекла из домена.

# application/commands.py
from dataclasses import dataclass
from datetime import date
from uuid import UUID

from domain.money import Money


@dataclass(frozen=True, slots=True)
class ChangePlan:
    subscription_id: UUID
    new_plan_code: str
    idempotency_key: str


@dataclass(frozen=True, slots=True)
class ChangePlanResult:
    """Результат сценария — не агрегат наружу, а ровно то, что нужно вызывающему."""
    charged_now: Money
    next_billing_date: date
# application/handlers.py
from datetime import timedelta

from domain.plan import PlanCode
from domain.ports import Clock, PlanCatalog, UnitOfWork
from domain.subscription import SubscriptionId

from .commands import ChangePlan, ChangePlanResult


class ChangePlanHandler:
    def __init__(self, uow: UnitOfWork, plans: PlanCatalog, clock: Clock) -> None:
        self._uow, self._plans, self._clock = uow, plans, clock

    def __call__(self, cmd: ChangePlan) -> ChangePlanResult:
        with self._uow as uow:
            subscription = uow.subscriptions.get(SubscriptionId(cmd.subscription_id))
            new_plan = self._plans.get(PlanCode(cmd.new_plan_code))

            delta = subscription.change_plan(new_plan, on=self._clock.today())

            uow.subscriptions.save(subscription)
            uow.commit()          # состояние + события в outbox — одной транзакцией
            # дата следующего списания = день после конца оплаченного периода:
            # смена плана её не сдвигает — это правило из раздела 1
            return ChangePlanResult(
                charged_now=delta,
                next_billing_date=subscription.period.end + timedelta(days=1),
            )

Разбор по строкам:

Строка Кто отвечает Почему не в домене
with self._uow приложение транзакция — свойство сценария, а не правила
subscriptions.get(...) приложение агрегат сам себя не загружает
plans.get(...) приложение домен получает снимок плана готовым
subscription.change_plan(...) домен все «если» — здесь, и только здесь
uow.commit() приложение момент фиксации выбирает сценарий

Чего в обработчике не должно быть никогда: if subscription.status == "active" (это решение домена), if delta.amount > 0: charge_card(...) без явного доменного правила, а также прямого похода в брокер сообщений — события уходят через outbox при коммите.

Идемпотентность (idempotency_key) — тоже забота прикладного слоя: перед выполнением сценарий ищет ключ в таблице обработанных команд и возвращает прошлый результат. Клиент, повторивший POST по таймауту, не должен сменить план дважды.


6. Транзакция: агрегат и события уходят вместе

Сквозной поток одной команды, включая outbox и доставку в другой контекст:

Здесь видно главное свойство схемы: состояние агрегата и факт события фиксируются одним COMMIT. Если бы мы отправили событие в Kafka до коммита (или после — отдельным вызовом), мы получили бы классическую проблему двойной записи: событие ушло, транзакция откатилась, соседний контекст живёт в несуществующей реальности. Разбор паттерна — transactional outbox и статья 05 трека.

# infrastructure/unit_of_work.py
class PgUnitOfWork:
    def __init__(self, conn_factory, event_serializer) -> None:
        self._conn_factory = conn_factory
        self._serialize = event_serializer
        self._tracked: list[Subscription] = []

    def __enter__(self) -> "PgUnitOfWork":
        self._conn = self._conn_factory()
        self._conn.autocommit = False
        self.subscriptions = PgSubscriptionRepo(self._conn, track=self._tracked.append)
        return self

    def commit(self) -> None:
        # события собираются ПОСЛЕ всех изменений, но ДО COMMIT
        with self._conn.cursor() as cur:
            for aggregate in self._tracked:
                for event in aggregate.pull_events():
                    cur.execute(
                        "INSERT INTO outbox (id, aggregate_id, type, payload, occurred_at) "
                        "VALUES (%s, %s, %s, %s, %s)",
                        self._serialize(event),
                    )
        self._conn.commit()

    def __exit__(self, exc_type, *_) -> None:
        if exc_type is not None:
            self._conn.rollback()   # исключение домена = откат, без вариантов
        self._conn.close()

7. Персистентность: агрегат ≠ таблица

Схема специально не повторяет структуру классов один в один — это нормально и правильно.

Три обязательных решения на этой схеме:

  1. price_amount копируется в подписку, а не берётся джойном из plan. Цена плана завтра изменится, а клиент по договору сидит на старой — модель обязана это выражать. Это не денормализация ради скорости, это доменный факт.
  2. version — не техническое поле «для ORM», а часть контракта репозитория.
  3. outbox в той же базе, что и агрегат. Другая база — и атомарности нет.

Репозиторий: ручной маппинг, потому что он честно показывает, где проходит граница.

# infrastructure/pg_subscription_repo.py
class ConcurrencyConflict(RuntimeError): ...
class SubscriptionNotFound(LookupError): ...


class PgSubscriptionRepo:
    def __init__(self, conn, track) -> None:
        self._conn, self._track = conn, track

    def get(self, subscription_id: SubscriptionId) -> Subscription:
        with self._conn.cursor(row_factory=dict_row) as cur:
            cur.execute("SELECT * FROM subscription WHERE id = %s", (subscription_id,))
            row = cur.fetchone()
        if row is None:
            raise SubscriptionNotFound(subscription_id)

        # ручная сборка: агрегат не наследуется от базового класса ORM и ничего о ней не знает
        aggregate = Subscription(
            subscription_id=subscription_id,
            customer_id=CustomerId(row["customer_id"]),
            plan_code=PlanCode(row["plan_code"]),
            price=Money(row["price_amount"], row["price_currency"]),
            period=BillingPeriod(row["period_start"], row["period_end"]),
            status=Status(row["status"]),
            seats=row["seats"], max_seats=row["max_seats"],
            assigned_seats=row["assigned_seats"],
            trial_used=row["trial_used"], grace_until=row["grace_until"],
            version=row["version"],
        )
        self._track(aggregate)   # UoW будет знать, у кого забирать события при commit
        return aggregate

    def save(self, s: Subscription) -> None:
        with self._conn.cursor() as cur:
            cur.execute(
                "UPDATE subscription SET plan_code=%s, price_amount=%s, price_currency=%s, "
                "seats=%s, assigned_seats=%s, max_seats=%s, status=%s, period_start=%s, "
                "period_end=%s, grace_until=%s, version = version + 1 "
                "WHERE id = %s AND version = %s",
                (s.plan_code, s.price.amount, s.price.currency, s.seats, s.assigned_seats,
                 s.max_seats, s.status.value, s.period.start, s.period.end,
                 s.grace_until, s.id, s.version),
            )
            if cur.rowcount == 0:
                # либо строки нет, либо кто-то обновил её между нашими SELECT и UPDATE
                raise ConcurrencyConflict(f"версия {s.version} устарела для {s.id}")

Сложность операций. get — один индексный поиск по первичному ключу, O(log n) в B-дереве, одна строка на выходе; save — один UPDATE по PK. Ни одной операции с ростом от числа подписок клиента: это прямое следствие того, что агрегат маленький. Если бы мы затащили внутрь агрегата историю списаний, каждая смена плана превратилась бы в загрузку O(k) строк ради проверки одного инварианта — типичная причина, по которой «DDD оказался медленным».

Почему ручной SQL, а не ORM. ORM тоже годится, но с оговоркой: агрегат не должен наследоваться от базового класса ORM. В SQLAlchemy это решается imperative mapping — чистые классы домена отображаются на таблицы отдельным модулем. Подробный пример такого маппинга с UoW разобран в свободно доступной книге Architecture Patterns with Python.

Чтение (списки, дашборды, отчёты) через агрегат не идёт: там отдельные read-модели и прямой SQL. Агрегат — про изменение с проверкой инвариантов, и оптимизировать его под выборки — верный способ испортить и то, и другое (см. CQRS в архитектурных паттернах).


8. API: перевод домена на HTTP

Внешний слой делает ровно две вещи: превращает JSON в команду и доменные исключения — в коды ответа.

# api/dto.py — форма запроса и ответа; про бизнес-правила здесь не знают
from decimal import Decimal
from uuid import UUID

from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field


class ChangePlanRequest(BaseModel):
    plan_code: str = Field(min_length=2, max_length=32, pattern=r"^[a-z0-9_]+$")


class ChangePlanResponse(BaseModel):
    charged_now: Decimal
    currency: str
    next_billing_date: str


# api/error_mapping.py — один словарь вместо десятка try/except в каждом эндпоинте
HTTP_STATUS: dict[type[Exception], int] = {
    SubscriptionNotFound: 404,
    InvalidTransition: 409,      # состояние не позволяет операцию
    SamePlan: 409,
    TrialAlreadyUsed: 409,
    SeatLimitExceeded: 422,      # запрос синтаксически верен, но нарушает правило
    ConcurrencyConflict: 409,    # клиенту стоит повторить запрос
}


# api/routes.py
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])


@router.post("/{subscription_id}/change-plan", response_model=ChangePlanResponse)
def change_plan(
    subscription_id: UUID,
    body: ChangePlanRequest,
    idempotency_key: str = Header(alias="Idempotency-Key"),
    handler: ChangePlanHandler = Depends(get_change_plan_handler),
) -> ChangePlanResponse:
    try:
        result = handler(ChangePlan(subscription_id, body.plan_code, idempotency_key))
    except tuple(HTTP_STATUS) as exc:
        # текст доменного исключения написан на языке предметной области —
        # его не стыдно показать клиенту
        raise HTTPException(status_code=HTTP_STATUS[type(exc)], detail=str(exc)) from exc

    # наружу уходит DTO, а не агрегат: внутренняя модель не становится публичным контрактом
    return ChangePlanResponse(
        charged_now=result.charged_now.amount,
        currency=result.charged_now.currency,
        next_billing_date=result.next_billing_date.isoformat(),
    )

Важный водораздел, который путают чаще всего:

  • Pydantic валидирует форму: строка ли plan_code, влезает ли в 32 символа. Это защита от мусора.
  • Домен валидирует смысл: существует ли такой план, разрешён ли переход, хватает ли мест. Это правило, за которое отвечает бизнес.

Дублировать доменные проверки в DTO — распространённая ошибка: правило начинает жить в двух местах, и через полгода они расходятся. Проверка формы в DTO — да; проверка «можно ли» — только в домене.


9. Тесты: главный дивиденд от такой раскладки

Ради этого раздела и затевалась вся конструкция. Тесты домена не требуют ни базы, ни моков, ни фикстур на 200 строк — потому что домен не общается с внешним миром.

# tests/unit/test_change_plan.py
import pytest
from datetime import date

NOV = BillingPeriod(date(2026, 11, 1), date(2026, 11, 30))


@pytest.mark.parametrize("day, expected", [
    (date(2026, 11, 1),  "4350.00"),   # 30/30 дней: 7500 − 3000
    (date(2026, 11, 12), "2850.00"),   # 19/30: 4750 − 1900
    (date(2026, 11, 30), "150.00"),    # 1/30:  250 − 100
])
def test_proration_is_linear_in_remaining_days(day, expected):
    sub = active_subscription(price=Money.rub("3000.00"), period=NOV)
    delta = sub.change_plan(plan("business", Money.rub("7500.00")), on=day)
    assert delta == Money.rub(expected)


def test_downgrade_below_assigned_seats_is_rejected():
    sub = active_subscription(assigned_seats=12)
    with pytest.raises(SeatLimitExceeded, match="освободите места"):
        sub.change_plan(plan("starter", Money.rub("900.00"), max_seats=5),
                        on=date(2026, 11, 12))


def test_expired_subscription_never_changes_plan():
    sub = expired_subscription()
    with pytest.raises(InvalidTransition):
        sub.change_plan(plan("business", Money.rub("7500.00")), on=date(2026, 11, 12))

Ни одного Mock(). Тест читается как утверждение о бизнесе — его можно показать продакту, и он скажет «да, так и есть» или «нет, тут по-другому». Это и есть проверка единого языка на прочность.

Второй уровень — сценарий с поддельным UoW:

class FakeUnitOfWork:
    """20 строк вместо контейнера с Postgres — и тест сценария бежит за миллисекунду."""

    def __init__(self, *subscriptions):
        self.subscriptions, self.committed = FakeRepo(subscriptions), False

    def __enter__(self): return self
    def __exit__(self, *exc): pass
    def commit(self): self.committed = True


def test_handler_does_not_commit_when_domain_rejects():
    uow = FakeUnitOfWork(expired_subscription(id=SUB_ID))
    handler = ChangePlanHandler(uow, FakePlans(), FrozenClock(date(2026, 11, 12)))

    with pytest.raises(InvalidTransition):
        handler(ChangePlan(SUB_ID, "business", "idem-1"))

    assert not uow.committed          # откат обязателен

Третий уровень — контрактные тесты репозитория: один и тот же набор тестов гоняется и на FakeRepo, и на PgSubscriptionRepo против настоящего Postgres в контейнере. Это единственный способ гарантировать, что фейк из юнит-тестов не врёт. Обязательный тест — на конфликт версий:

def test_concurrent_updates_conflict(pg_uow_factory):
    with pg_uow_factory() as uow_a, pg_uow_factory() as uow_b:
        a = uow_a.subscriptions.get(SUB_ID)
        b = uow_b.subscriptions.get(SUB_ID)     # обе транзакции видят version = 7
        a.assign_seats(5); uow_a.subscriptions.save(a); uow_a.commit()
        b.assign_seats(9)
        with pytest.raises(ConcurrencyConflict):
            uow_b.subscriptions.save(b)

Пирамида получается перевёрнутой относительно привычной «всё через API»: сотни быстрых доменных тестов (миллисекунды), десятки тестов сценариев, единицы e2e. На проекте с 3000 доменных тестов полный прогон занимает секунды — и именно поэтому люди действительно их запускают.


10. Trade-offs: во сколько это обошлось

Честный подсчёт по нашему примеру:

Слой Строк Что было бы в «толстом контроллере»
domain/ ~330 0
application/ ~60
infrastructure/ ~180 ~60 (ORM «из коробки»)
api/ ~70 ~120 (вся логика здесь)
Итого ~640 ~180

Первый эндпоинт в DDD-раскладке стоит втрое дороже. Дальше кривая расходится в другую сторону: второй эндпоинт (assign_seats) — 12 строк сценария + 6 строк домена, потому что Money, BillingPeriod, репозиторий и UoW уже есть. К пятой-шестой операции суммарный объём сравнивается, а к пятнадцатой DDD-версия оказывается меньше — в «толстом контроллере» к этому моменту три копии проверки «можно ли менять план» и две из них с расхождениями.

Где эта конструкция не окупается:

  • CRUD-контекст без правил (справочники, настройки) — там нечего защищать инвариантами;
  • прототип с горизонтом жизни в месяц;
  • команда из одного человека, который держит всю модель в голове и не собирается уходить.

Где окупается почти всегда: деньги, доступы, расписания, склад, всё, где ошибка стоит дороже недели разработки. Подробный разбор границы применимости — в обзорной статье трека.

Стоимость производительности обычно переоценивают: лишние аллокации объектов-значений и ручной маппинг дают единицы процентов, тогда как узкое место почти всегда — сеть и запросы. А вот реальная цена — когнитивная: новому разработчику нужно 2–3 дня, чтобы понять, почему нельзя «просто дописать if в контроллер».


11. Типичные ошибки в таком коде

  1. Публичные сеттеры «на всякий случай». Один subscription.status = "active" из скрипта миграции — и инвариант мёртв. Если нужен обход правил (перенос данных, поддержка) — делайте это явным методом с именем вроде force_status_by_support(...), который логирует и требует причину.
  2. Агрегат ходит в репозиторий. Появляется self._plan_repo.get(...) внутри change_plan — и всё: домен больше не тестируется без моков, а метод внезапно делает N запросов в цикле. Всё нужное передаётся аргументом.
  3. date.today() внутри домена. Тесты на границы периода становятся невозможны, а «пересчитать за прошлый месяц» превращается в подмену системного времени.
  4. Транзакция на два агрегата. Соблазн «заодно обновим клиента» ломает главное правило: одна транзакция — один агрегат. Согласованность между агрегатами — через события и eventual consistency (статья 05).
  5. События публикуются в брокер до коммита. Классическая двойная запись; лечится outbox.
  6. Обработчик «дорешивает» за домен: if sub.status == ACTIVE and delta.amount > 0: в прикладном слое — верный признак, что правило потеряло дом.
  7. Репозиторий возвращает ORM-модели наружу. Через месяц api/ начинает импортировать models.Subscription, и слоёв больше нет — есть один большой ком с папками.
  8. Инварианты продублированы в pydantic. Правило живёт в двух местах и расходится.
  9. Проверки инвариантов в SQL-триггерах. База начинает молча «поправлять» агрегат, и модель врёт. Ограничения в БД полезны как страховка (CHECK (seats > 0)), но не как источник правды.

Границу между «правило» и «валидация» удобно проверять вопросом: может ли бизнес завтра решить это изменить? Если да — это домен.


12. Как это выглядит в проде

  • Линтер границ. import-linter с контрактом domain → forbidden imports application/infrastructure/api в CI. Один файл конфига, ноль споров на код-ревью. В .NET то же делают через ArchUnitNET, в Java — ArchUnit.
  • Метрика отказов инвариантов. Счётчик по типам доменных исключений с меткой правила. Всплеск SeatLimitExceeded — это не баг кода, а сигнал, что пользователи хотят другого поведения; такие графики — лучший вход в следующую сессию моделирования.
  • Доменные ошибки — это 4xx, а не 5xx. Их не должно быть в алертах on-call. А вот ConcurrencyConflict выше 1 % запросов на эндпоинт — сигнал, что агрегат слишком «горячий» и границу стоит пересмотреть.
  • Версионирование событий. В outbox.payload пишется поле schema_version. Подписчики из соседних контекстов обновляются не одновременно с вами — никогда.
  • Миграции против агрегата. Добавление поля в агрегат = миграция + обновление маппинга + тест round-trip (сохранить → загрузить → сравнить). Round-trip-тест ловит забытые поля лучше ревью.
  • Read-модели отдельно. Списки подписок в админке читаются проекцией/вьюхой, а не тысячей repo.get(). Смешивать чтение и запись через один агрегат — самая частая причина жалоб на «медленный DDD».
  • Фича-флаги на уровне домена, а не эндпоинта: новое правило пропорции включается параметром политики, чтобы старые подписки досчитывались по-старому. Домен, который умеет две версии правила одновременно, — норма для биллинга.

13. Мини-итог

  • Порядок сборки контекста: значения → агрегат → порты → сценарий → инфраструктура → API → тесты. Обратный порядок (от таблиц) почти всегда даёт анемичную модель.
  • Агрегат маленький, без сеттеров, без обращений к базе, со временем и снимками данных, переданными снаружи. Метод возвращает результат решения.
  • Прикладной слой — транспорт: загрузить, вызвать, сохранить, зафиксировать. Ни одного бизнес-«если».
  • Транзакция и outbox — одно целое; версия агрегата — часть контракта репозитория.
  • API переводит форму в команду и доменные исключения в коды; смысловая валидация остаётся в домене.
  • Плата — примерно троекратный объём кода на первой операции, окупается к пятой-пятнадцатой и в контекстах со сложными правилами.

Источники


Что дальше

Код выше — «как надо». Но большинство внедрений DDD ломаются не на технике, а на предсказуемом наборе организационных и модельных ловушек: DDD без экспертов, контексты по таблицам, агрегаты размером с базу, «мы сделали папки domain/application и теперь у нас DDD».

Типичные ошибки внедрения DDD — разбор этих ловушек и способов из них выбираться.

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

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

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

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