DDD в коде: полный пример от домена до API
Шесть предыдущих статей трека были про то, как думать: где границы (контексты), из чего собирать модель (тактические блоки), как её доставать из головы экспертов (Event Storming). Эта статья — про то, как всё это выглядит в репозитории, который можно склонировать и запустить.
Мы возьмём один контекст — управление подписками SaaS — и пройдём его насквозь: от объекта-значения
Money до HTTP-эндпоинта и тестов. Не «пример ради примера»: у подписок настоящие, неприятные,
взаимозависимые правила — пробный период, смена плана в середине оплаченного месяца, места (seats),
просрочка платежа, отмена с догоранием до конца периода. Именно на таких правилах анемичная модель
разваливается первой.
Читая, держите в голове главный вопрос статьи:
где живёт решение? Каждое «если» в коде должно иметь ровно один законный адрес, и по адресу должно быть понятно, кто за него отвечает.
1. Постановка: единый язык до единой строки кода
Выпишем правила так, как их формулирует продакт (это буквально результат сессии Event Storming):
- Подписка начинается с пробного периода на 14 дней. Пробный период даётся клиенту один раз — повторно trial не выдаётся даже после отмены.
- Пока подписка активна, клиент может сменить план в любой день. Деньги пересчитываются пропорционально остатку оплаченного периода; дата следующего списания не сдвигается.
- Число мест нельзя опустить ниже числа уже назначенных сотрудников и нельзя поднять выше лимита плана.
- Если списание не прошло — подписка переходит в просрочку и живёт так 7 дней (grace period), продолжая работать. Успешная оплата возвращает её в активные, иначе она истекает.
- Отмена не выключает подписку немедленно: она становится отменяемой и работает до конца оплаченного периода. До этого момента отмену можно отозвать.
- Истёкшая подписка — терминальное состояние. Новую подписку заводят заново (и уже без пробного периода).
Обратите внимание: в этих шести пунктах нет ни одного слова «таблица», «джоба», «эндпоинт». Это и есть
единый язык, и почти каждое существительное/глагол оттуда
появится в коде под тем же именем. Если в коде появится 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, который спасает архитектуру
надёжнее, чем любые договорённости на ретро.
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 и доставку в другой контекст:
в оперативной памяти H->>R: save(subscription) R->>DB: UPDATE ... SET version = 8 WHERE id = $1 AND version = 7 alt rowcount = 0 DB-->>R: конфликт версий R-->>H: ConcurrencyConflict H->>DB: ROLLBACK H-->>A: 409 Conflict (клиент повторяет) else успех H->>DB: INSERT INTO outbox (PlanChanged) H->>DB: COMMIT H-->>A: delta = 2850.00 RUB A-->>C: 200 OK end O->>DB: SELECT ... FROM outbox WHERE published_at IS NULL O->>K: publish(PlanChanged) O->>DB: UPDATE outbox SET published_at = now()
Здесь видно главное свойство схемы: состояние агрегата и факт события фиксируются одним 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. Персистентность: агрегат ≠ таблица
Схема специально не повторяет структуру классов один в один — это нормально и правильно.
Три обязательных решения на этой схеме:
price_amountкопируется в подписку, а не берётся джойном изplan. Цена плана завтра изменится, а клиент по договору сидит на старой — модель обязана это выражать. Это не денормализация ради скорости, это доменный факт.version— не техническое поле «для ORM», а часть контракта репозитория.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. Типичные ошибки в таком коде
- Публичные сеттеры «на всякий случай». Один
subscription.status = "active"из скрипта миграции — и инвариант мёртв. Если нужен обход правил (перенос данных, поддержка) — делайте это явным методом с именем вродеforce_status_by_support(...), который логирует и требует причину. - Агрегат ходит в репозиторий. Появляется
self._plan_repo.get(...)внутриchange_plan— и всё: домен больше не тестируется без моков, а метод внезапно делает N запросов в цикле. Всё нужное передаётся аргументом. date.today()внутри домена. Тесты на границы периода становятся невозможны, а «пересчитать за прошлый месяц» превращается в подмену системного времени.- Транзакция на два агрегата. Соблазн «заодно обновим клиента» ломает главное правило: одна транзакция — один агрегат. Согласованность между агрегатами — через события и eventual consistency (статья 05).
- События публикуются в брокер до коммита. Классическая двойная запись; лечится outbox.
- Обработчик «дорешивает» за домен:
if sub.status == ACTIVE and delta.amount > 0:в прикладном слое — верный признак, что правило потеряло дом. - Репозиторий возвращает ORM-модели наружу. Через месяц
api/начинает импортироватьmodels.Subscription, и слоёв больше нет — есть один большой ком с папками. - Инварианты продублированы в pydantic. Правило живёт в двух местах и расходится.
- Проверки инвариантов в SQL-триггерах. База начинает молча «поправлять» агрегат, и модель врёт.
Ограничения в БД полезны как страховка (
CHECK (seats > 0)), но не как источник правды.
Границу между «правило» и «валидация» удобно проверять вопросом: может ли бизнес завтра решить это изменить? Если да — это домен.
12. Как это выглядит в проде
- Линтер границ. import-linter с контрактом
domain→ forbidden importsapplication/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 переводит форму в команду и доменные исключения в коды; смысловая валидация остаётся в домене.
- Плата — примерно троекратный объём кода на первой операции, окупается к пятой-пятнадцатой и в контекстах со сложными правилами.
Источники
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) — главы 5–7 про блоки и границы агрегата.
- Vaughn Vernon. Implementing Domain-Driven Design (2013) и серия Effective Aggregate Design — три статьи PDF с правилами размера агрегата.
- H. Percival, B. Gregory. Architecture Patterns with Python — бесплатно онлайн; репозиторий, UoW, события на живом коде.
- Martin Fowler. Anemic Domain Model, Unit of Work.
- Alistair Cockburn. Hexagonal Architecture.
- Chris Richardson. Transactional Outbox.
- Vladimir Khorikov. Domain model purity vs. completeness — честный разбор компромиссов, когда «чистый домен» мешает.
- Документация: SQLAlchemy imperative mapping, Python decimal, FastAPI dependencies.
Что дальше
Код выше — «как надо». Но большинство внедрений DDD ломаются не на технике, а на предсказуемом наборе организационных и модельных ловушек: DDD без экспертов, контексты по таблицам, агрегаты размером с базу, «мы сделали папки domain/application и теперь у нас DDD».
Типичные ошибки внедрения DDD — разбор этих ловушек и способов из них выбираться.