DDD: зачем нужен, когда не нужен и карта трека
Domain-Driven Design — не фреймворк, не набор папок Domain/Application/Infrastructure и не обязанность
называть классы «агрегатами». Это дисциплина, которая отвечает на один вопрос:
где в коде живёт знание о том, как работает бизнес, и почему его там легко найти и безопасно менять?
Всё остальное в DDD — следствие. Ниже мы разберём проблему с первых принципов, посмотрим, где подход окупается и где вредит, напишем сравнительный код «до/после», а в конце — карта трека: что и в каком порядке читать дальше.
1. Проблема: знание о домене испаряется
Возьмём реальную задачу. Интернет-магазин, правило:
«Заказ нельзя отменить после того, как он передан в доставку — кроме случая, когда клиент имеет статус Premium и с момента передачи прошло меньше 30 минут; при отмене замороженные бонусные баллы возвращаются, а зарезервированный товар освобождается».
Такое правило почти никогда не живёт в одном месте. Оно распадается на:
ifв HTTP-контроллере (проверка статуса),- ветку в сервисном методе
OrderService.cancel(), - триггер в базе, который списывает резерв,
- фоновой job, который «догоняет» бонусы раз в час,
- строчку в фронтенде, которая прячет кнопку «Отменить».
Через полгода никто не может ответить на вопрос «а можно ли отменить заказ?» — не читая пять модулей. Через год добавляют шестое место, и правило начинает противоречить само себе. Именно это Эрик Эванс называет анемичной моделью: объекты хранят данные, а поведение размазано по процедурам (Fowler, «AnemicDomainModel»).
Оценка «сложности» по-инженерному
Пусть в системе n бизнес-правил и m мест в коде, откуда меняется состояние заказа.
- Без модели каждое место обязано знать про каждое правило: поверхность корректности —
O(n · m). Добавление одного правила требуетO(m)правок, и каждая — шанс на баг. Тестов, чтобы покрыть комбинации, нужно тоже порядкаO(n · m). - С агрегатом, который единственный владеет переходами состояния, правила проверяются в одной точке:
поверхность —
O(n), добавление правила —O(1)правок, тесты —O(n)юнит-тестов без БД и HTTP.
Это и есть главный экономический аргумент DDD: он не делает код быстрее и не уменьшает объём кода, он уменьшает число мест, где можно ошибиться.
2. Что такое DDD строго: три уровня
DDD — это три слоя практик, и их часто путают.
- Стратегический уровень отвечает на вопрос «какие вообще у нас модели и где границы между ними». Это про людей, команды и деньги: что мы пишем сами, что покупаем, где проходят швы системы. Ошибка здесь стоит месяцев. Разбираем в Стратегический DDD и Ограниченные контексты.
- Тактический уровень — как выразить одну модель в коде: агрегаты, инварианты, репозитории. Ошибка здесь стоит недель. См. Тактические блоки и Репозитории и персистентность.
- Процесс — как вообще добыть модель из голов экспертов: Event Storming.
Ключевое, что стоит запомнить сразу: стратегия важнее тактики. Идеальные агрегаты внутри неправильно проведённой границы контекста — это красиво оформленный тупик. Обратное неверно: правильные границы спасают даже посредственный код внутри.
3. Единый язык — не «глоссарий», а тест на понимание
Ubiquitous Language (единый язык) — это требование, чтобы одно слово означало одно и то же
в разговоре с бизнесом, в тикете, в тесте и в имени класса. Не «бизнес говорит отгрузка, а в коде
ShipmentEntityDtoV2», а буквально одно слово.
Практический критерий, работает ли язык: прочитайте вслух эксперту тело метода домена.
Если он понимает и говорит «нет, тут не так» — язык есть. Если он слышит repository.save(dto) — языка нет.
# Языка НЕТ: эксперт не понимает ни строчки
def process(self, order_id: int, flag: bool) -> None:
o = self.dao.load(order_id)
o.status = 7 if flag else 3
self.dao.update(o)
# Язык ЕСТЬ: читается вслух и проверяется экспертом
def cancel(self, order: Order, reason: CancellationReason, now: datetime) -> None:
order.cancel(reason, now) # «клиент отменяет заказ по причине X»
self.bonuses.release(order.id) # «замороженные баллы возвращаются»
Важный нюанс: единый язык не глобален. Слово «Клиент» в биллинге (плательщик, ИНН, лимит кредита)
и в поддержке (человек с историей обращений) — разные понятия. Попытка сделать один общий Customer
на всю компанию — самая частая и самая дорогая ошибка. Отсюда и вырастают ограниченные контексты.
4. Куда DDD кладёт код: направление зависимостей
DDD сам по себе не диктует архитектуру, но практически всегда сочетается с «луковой»/гексагональной компоновкой: доменная модель в центре и не зависит ни от чего внешнего.
Правило одно и жёсткое: стрелки зависимостей смотрят внутрь. Домен не импортирует ORM, HTTP-клиент, брокер сообщений и «утилиты проекта». Интерфейс репозитория объявлен в домене, реализация — снаружи.
Это не эстетика. Это ровно то, что позволяет тестировать бизнес-правила за микросекунды без Docker, БД и моков половины мира. Подробности — в Репозитории и персистентность и в треке Архитектурные паттерны.
5. «До и после» на живом коде
5.1. Транзакционный скрипт
Так пишут по умолчанию, и до определённого размера это правильно.
class OrderService:
def __init__(self, db, bonuses, warehouse):
self.db, self.bonuses, self.warehouse = db, bonuses, warehouse
def cancel_order(self, order_id: int, user_id: int) -> None:
row = self.db.query("SELECT * FROM orders WHERE id = %s", order_id)
if row["status"] == "CANCELLED":
raise ValueError("уже отменён")
if row["status"] == "SHIPPED":
# правило про Premium — здесь и ещё в трёх местах кодовой базы
user = self.db.query("SELECT tier FROM users WHERE id = %s", user_id)
minutes = (datetime.now(UTC) - row["shipped_at"]).total_seconds() / 60
if not (user["tier"] == "PREMIUM" and minutes < 30):
raise ValueError("отмена невозможна")
self.db.execute("UPDATE orders SET status='CANCELLED' WHERE id=%s", order_id)
self.bonuses.refund(user_id, row["bonus_frozen"]) # если упадёт — статус уже изменён
self.warehouse.release(order_id) # и здесь тоже
Что здесь плохо объективно, а не «некрасиво»:
- Правило отмены не имеет единственного места жительства — его нельзя ни найти, ни протестировать целиком.
- Порядок «сначала запись, потом побочные эффекты» негерметичен: падение на строке 3 оставит систему в противоречивом состоянии.
- Невозможно ответить на вопрос «какие переходы статуса вообще легальны» — нет ни одного места, где это записано.
5.2. Доменная модель
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
class OrderStatus(Enum):
NEW = "new"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class OrderCancellationForbidden(Exception):
"""Нарушение инварианта домена, а не техническая ошибка."""
@dataclass(frozen=True)
class Money:
"""Объект-значение: без идентичности, неизменяемый, сравнивается по значению."""
amount: int # в минорных единицах — никаких float в деньгах
currency: str = "RUB"
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("нельзя складывать разные валюты")
return Money(self.amount + other.amount, self.currency)
@dataclass(frozen=True)
class OrderCancelled:
"""Доменное событие: факт, который уже произошёл. Прошедшее время в имени."""
order_id: str
refunded_bonuses: Money
occurred_at: datetime
@dataclass
class Order:
"""Агрегат: единственный вход для изменения заказа и его строк."""
id: str
customer_is_premium: bool
status: OrderStatus
frozen_bonuses: Money
shipped_at: datetime | None = None
events: list[object] = field(default_factory=list)
# окно отмены после отгрузки — часть языка домена, а не «магическое число»
PREMIUM_CANCEL_WINDOW = timedelta(minutes=30)
def can_cancel(self, now: datetime) -> bool:
if self.status in (OrderStatus.CANCELLED, OrderStatus.DELIVERED):
return False
if self.status is not OrderStatus.SHIPPED:
return True
assert self.shipped_at is not None
return (
self.customer_is_premium
and now - self.shipped_at < self.PREMIUM_CANCEL_WINDOW
)
def cancel(self, now: datetime) -> None:
if not self.can_cancel(now):
raise OrderCancellationForbidden(
f"заказ {self.id} в статусе {self.status.value} отменить нельзя"
)
self.status = OrderStatus.CANCELLED
self.events.append(
OrderCancelled(self.id, self.frozen_bonuses, occurred_at=now)
)
self.frozen_bonuses = Money(0)
def pull_events(self) -> list[object]:
"""Забрать накопленные события и очистить буфер — публикует их прикладной слой."""
collected, self.events = self.events, []
return collected
Прикладной слой становится тонким и говорит только про транзакцию:
class CancelOrder:
"""Сценарий использования: загрузить — вызвать домен — сохранить — опубликовать."""
def __init__(self, orders, uow, bus):
self.orders, self.uow, self.bus = orders, uow, bus
def __call__(self, order_id: str, now: datetime) -> None:
with self.uow: # одна транзакция = один агрегат
order = self.orders.get(order_id)
order.cancel(now) # всё решение принято внутри домена
self.orders.save(order)
events = order.pull_events()
self.bus.publish(events) # эффекты — ПОСЛЕ фиксации транзакции
А тест бизнес-правила не требует ни базы, ни контейнеров:
def test_premium_can_cancel_within_30_minutes():
shipped = datetime(2026, 7, 16, 12, 0)
order = Order("o-1", customer_is_premium=True, status=OrderStatus.SHIPPED,
frozen_bonuses=Money(500), shipped_at=shipped)
order.cancel(shipped + timedelta(minutes=29))
assert order.status is OrderStatus.CANCELLED
assert isinstance(order.events[-1], OrderCancelled)
def test_regular_customer_cannot_cancel_shipped_order():
order = Order("o-2", customer_is_premium=False, status=OrderStatus.SHIPPED,
frozen_bonuses=Money(0), shipped_at=datetime(2026, 7, 16, 12, 0))
with pytest.raises(OrderCancellationForbidden):
order.cancel(datetime(2026, 7, 16, 12, 1))
Три таких теста заменяют десяток интеграционных, которые раньше поднимали Postgres ради проверки if.
5.3. Что мы получили и чем заплатили
| Транзакционный скрипт | Доменная модель | |
|---|---|---|
| Время до первой фичи | часы | дни |
| Стоимость 20-го правила | растёт нелинейно | почти константа |
| Тест бизнес-правила | интеграционный, секунды | юнит, микросекунды |
| Порог входа для новичка | низкий | средний/высокий |
| Мэппинг на БД | тривиальный | требует работы (см. статью 04) |
| Оправдан при | простом CRUD | сложных, меняющихся правилах |
6. Когда DDD не нужен
Это самая недооценённая часть темы. DDD платный: он берёт наценку авансом и возвращает её позже.
Слева от точки окупаемости честный SELECT/UPDATE выигрывает по всем метрикам. Справа — проигрывает
катастрофически. Задача архитектора — понять, где вы находитесь, а не занять позицию «всегда DDD».
Правый нижний квадрант особенно поучителен: бухучёт — чудовищно сложный домен, но он не даёт вам преимущества перед конкурентами. Его надо покупать, а не моделировать.
Чек-лист принятия решения
или конкурентное отличие?} C -- Нет --> D{Есть готовый продукт на рынке?} D -- Да --> BUY[Купить или взять SaaS] D -- Нет --> SIMPLE[Простая модель без тяжёлой тактики] C -- Да --> E{Есть доступ к эксперту домена
хотя бы несколько часов в неделю?} E -- Нет --> WARN[Красный флаг:
DDD без эксперта = выдуманная модель] E -- Да --> F{Правила будут меняться
в ближайший год?} F -- Нет --> SIMPLE F -- Да --> DDD[Полный DDD:
контексты + агрегаты + события] style DDD fill:#2a9d8f,color:#fff style CRUD fill:#4a7ba7,color:#fff style BUY fill:#6c757d,color:#fff style WARN fill:#d1495b,color:#fff
Отдельно про «нет эксперта». Это не мелочь, а стоп-фактор: DDD — это способ перенести чужое знание в код. Если знания нет, вы перенесёте собственные фантазии, и они будут гораздо хуже, чем честный CRUD, потому что окажутся зацементированы в «красивой архитектуре».
Сигналы, что DDD вам сейчас не нужен:
- прототип, гипотеза, MVP на выброс со сроком жизни в недели;
- ETL и аналитика — там правит трансформация данных, а не инварианты (см. Data Engineering);
- CRUD-админки, справочники, конфигурационные экраны;
- команда из 1–2 человек на всю систему без разделения контекстов;
- домен, который вы обязаны реализовывать буква-в-букву по внешнему регламенту (тогда модель уже написана регулятором — переносите её, а не переизобретайте).
7. Как это выглядит в проде
Несколько наблюдений, которые редко попадают в книги.
Границы контекстов дороже классов. Перенести правило из сервиса в агрегат — работа на день. Разделить сросшиеся контексты, у которых общая база и общие таблицы, — работа на кварталы. Поэтому стратегическую часть делают рано и обсуждают публично, а тактическую — итеративно. Это же аргумент против «начнём с микросервисов»: сначала контексты в монолите («модульный монолит»), микросервисы — потом, когда границы доказали устойчивость.
Один агрегат — одна транзакция. Практическое правило Вернона: в рамках одной транзакции меняем ровно один экземпляр агрегата; согласованность между агрегатами — через доменные события и eventual consistency. Нарушение этого правила — источник дедлоков и «загадочных» блокировок в проде. Подробно — в Доменные события и интеграция.
Модель не пишется один раз. Эванс отдельно подчёркивает: без постоянного рефакторинга модель деградирует за месяцы. Практика зрелых команд — регулярно (раз в квартал) пересматривать именование и границы вместе с бизнесом, а не только код-ревью на уровне синтаксиса.
DDD и legacy. В существующей системе не бывает «переписать всё». Работает паттерн Strangler Fig: новый контекст с чистой моделью растёт рядом, старому оставляют «антикоррупционный слой» — переводчик между чужой моделью и вашей (Fowler, «StranglerFigApplication»).
Событийная эволюция подхода. Полезно помнить хронологию: сообщество прошло путь от «слоёв и агрегатов» к «границам и событиям».
8. Типичные ошибки (обзорно)
Полный разбор — в Типичные ошибки внедрения DDD, здесь — список, чтобы вы узнавали их с первой статьи.
- DDD как структура папок. Создали
Domain/, положили туда те же анемичные классы — ничего не изменилось, кроме длины импортов. - Одна модель на всю компанию. «Единый
Customerдля всех» — прямой путь к классу на 3000 строк, который никому не подходит. - Агрегат = таблица. Границы агрегата определяются инвариантами, а не схемой БД. Если два объекта не обязаны быть согласованы мгновенно — это два агрегата.
- Гигантский агрегат.
Customer, включающий все заказы, — гарантированные конфликты блокировок. Ссылайтесь между агрегатами по идентификатору, а не по объекту. - Тактика без стратегии. Value objects и репозитории при полном игноре границ контекстов — самая частая форма карго-культа.
- DDD там, где CRUD. Три слоя, маппинг и события ради формы «создать/удалить справочник».
- Модель без эксперта. Разработчики «сами придумали, как работает бизнес».
- Утечка персистентности в домен. Аннотации ORM, ленивая загрузка и
sessionвнутри агрегата превращают доменную модель в надстройку над таблицами.
9. Карта трека
вы здесь] --> B[01. Стратегический DDD
домен, поддомены, язык] B --> C[02. Ограниченные контексты
карта контекстов] C --> D[03. Тактические блоки
сущности, VO, агрегаты] D --> E[04. Репозитории
и персистентность] D --> F[05. Доменные события
и интеграция] C --> G[06. Event Storming
практики моделирования] E --> H[07. DDD в коде
сквозной пример] F --> H G --> H H --> I[08. Типичные ошибки] style A fill:#2a9d8f,color:#fff
| Статья | О чём | Кому особенно важно |
|---|---|---|
| 01. Стратегический DDD | Домен, ядро и поддомены, единый язык, «строить или купить» | Архитекторы, тимлиды, продакты |
| 02. Ограниченные контексты | Границы моделей, паттерны интеграции, context map | Все, кто режет систему на части |
| 03. Тактические блоки | Сущности, объекты-значения, агрегаты, доменные сервисы | Разработчики |
| 04. Репозитории и персистентность | Unit of Work, мэппинг, как не протечь ORM в домен | Бэкенд-разработчики |
| 05. Доменные события | События, outbox, согласованность между контекстами | Проектирующие распределённые системы |
| 06. Event Storming | Как добыть модель из голов экспертов за один день | Фасилитаторы, аналитики, лиды |
| 07. DDD в коде | Полный пример: домен → приложение → API → тесты | Практики |
| 08. Типичные ошибки | Антипаттерны и как из них выбираться | Все |
Как читать. Если вы принимаете архитектурные решения — 01 → 02 → 06, остальное по мере надобности. Если вы пишете код в уже нарезанном контексте — 03 → 04 → 05 → 07. Полный проход подряд даёт цельную картину и занимает несколько вечеров.
Смежные треки портала. DDD стыкуется с архитектурными паттернами (гексагональная архитектура, CQRS, saga), паттернами проектирования (фабрики, стратегии внутри домена) и принципами разработки (SOLID, YAGNI — последний особенно важен, чтобы не переусердствовать).
10. Мини-итог
- DDD решает не техническую, а когнитивную проблему: где живёт знание о бизнесе и как его безопасно менять.
- Экономика подхода: убрать дублирование правил, снизив поверхность корректности с
O(n · m)доO(n). - Стратегия (границы, язык) важнее тактики (агрегаты, репозитории). Обратный порядок — карго-культ.
- DDD платный. В простом CRUD, в MVP, в аналитике и при отсутствии эксперта домена он вреден.
- Направление зависимостей внутрь и правило «одна транзакция — один агрегат» — два практических правила, которые дают львиную долю пользы.
Источники
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) — первоисточник; бесплатная выжимка Domain-Driven Design Reference.
- Vaughn Vernon. Implementing Domain-Driven Design (2013) — практические правила агрегатов; бесплатная серия Effective Aggregate Design.
- Vlad Khononov. Learning Domain-Driven Design (O’Reilly, 2021) — самое доступное современное введение.
- Martin Fowler. Domain-Driven Design, BoundedContext, UbiquitousLanguage, AnemicDomainModel.
- Percival & Gregory. Architecture Patterns with Python — бесплатно на cosmicpython.com — лучший источник по коду с агрегатами, UoW и событиями.
- Alberto Brandolini. Introducing EventStorming.
- Microsoft. .NET Microservices: Architecture for Containerized .NET Applications — DDD-глава.
- DDD Crew — открытые шаблоны: Context Mapping, Bounded Context Canvas, Core Domain Charts.
Что дальше
Дальше — самое важное и самое дешёвое в исправлении: границы и язык. Разберём, как отличить ядро домена от вспомогательного, где проходит граница «строить или купить» и как вырастить единый язык, который переживёт три состава команды.