Типичные ошибки внедрения DDD
Восемь статей назад мы начали с вопроса «где в коде живёт знание о бизнесе». К этому моменту у вас есть весь инструментарий: стратегия, контексты, тактические блоки, персистентность, события, Event Storming и работающий код.
Проблема в том, что инструментарий — не самая частая причина провала. Внедрения DDD ломаются
предсказуемо и почти всегда одинаково, и почти никогда — из-за того, что кто-то неправильно
написал Money.__eq__. Ломаются они потому, что:
DDD оптимизирует стоимость изменения через N лет, а оценивают его по стоимости первой недели.
Отсюда две симметричные катастрофы. Первая: команда платит стартовую наценку и не получает ничего, потому что скопировала форму (папки, слова, слои), а не содержание (границы, инварианты, язык). Вторая: команда платит наценку там, где её вообще не надо было платить — домен оказался CRUD-справочником.
Эта статья — каталог таких провалов. Для каждого: признак (как понять, что это про вас, не советуясь с архитектором), механика (почему это дорого именно в деньгах и инцидентах), выход (что делать в понедельник, не останавливая продукт). В конце — таблица «симптом → диагноз», метрики здоровья модели и приоритизация: что чинить первым.
1. Карта ловушек
Ошибки живут на четырёх уровнях, и это не декоративная классификация: лечатся они принципиально по-разному. Ошибку уровня кода правит одна команда за спринт. Ошибку уровня границ — квартал и переговоры. Ошибку уровня организации нельзя починить рефакторингом вообще.
внедрения DDD)) Уровень решения DDD в CRUD-домене DDD в ядре и в вспомогательном одинаково Big bang переписывание Тактика без стратегии Уровень границ Контексты по таблицам Общая БД как интеграция Распределённый монолит Shared kernel из всего подряд Уровень модели Анемичная модель в DDD-обёртке Агрегат размером со схему БД Транзакция на два агрегата События как CRUD-нотификации Репозиторий как DAO Уровень людей Единый язык без экспертов Глоссарий вместо разговора Контекст без владельца DDD-полиция и ритуалы
Дальше — по уровням снизу вверх по частоте: самые массовые ошибки идут первыми.
2. Уровень решения: DDD там, где домена нет
2.1. Признак
Возьмите три последних тикета и выпишите бизнес-правила, которые они меняли. Если в них нет ни одного слова «нельзя», «только если», «кроме случая», а есть «добавить поле», «показать колонку», «выгрузить в Excel» — у вас не домен, а редактор данных.
Второй тест, ещё честнее: сколько инвариантов в вашей самой сложной сущности? Если ответ «уникальность email и обязательность имени» — это валидация формы, а не доменная логика. Инвариант домена — это правило, которое связывает несколько полей или сущностей и которое нельзя проверить в момент ввода данных.
2.2. Механика
Стоимость DDD — не абстрактная «сложность». Она измерима: на каждый сценарий вы платите объектом-значением, агрегатом, портом, репозиторием, мапперами domain↔ORM и domain↔DTO. В примере из седьмой статьи это примерно в 2,5–3 раза больше строк, чем прямолинейный CRUD, и — что важнее — три места вместо одного, куда надо зайти, чтобы добавить поле.
Эта наценка окупается только тем, что правила не размазываются. Если размазываться нечему — наценка остаётся чистым убытком, и ещё она отпугивает джунов от вашего репозитория.
2.3. Выход
Разделите систему по типам поддоменов и применяйте разные стили в разных местах — это нормально и правильно:
| Поддомен | Стиль | Почему |
|---|---|---|
| Ядро (core) | Полный DDD: агрегаты, VO, события | Здесь конкурентное преимущество и максимум изменений |
| Поддерживающий (supporting) | Транзакционный скрипт + аккуратные сервисы | Правил мало, меняются редко |
| Обобщённый (generic) | Купить / взять готовое | Уведомления, биллинг-провайдер, auth |
Главная ошибка тут — не «применили DDD», а «применили его равномерно». Единый архитектурный стандарт на весь монорепозиторий выглядит красиво в презентации и стоит компании квартал разработки в год.
3. Уровень модели: папки вместо модели
Самый массовый провал. Команда прочитала книгу, создала domain/, application/, infrastructure/,
переименовала OrderService в OrderApplicationService — и на этом остановилась. Внутри всё то же самое.
3.1. Как это выглядит в коде
# domain/order.py — «доменная модель»
from sqlalchemy.orm import Mapped, mapped_column # ← протечка №1
from infrastructure.db import Base # ← протечка №2
class Order(Base): # ← наследование от ORM
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
status: Mapped[str]
total: Mapped[float] # ← деньги во float
customer_id: Mapped[int]
def set_status(self, status: str) -> None: # ← «метод» без инварианта
self.status = status
# application/order_service.py — здесь живут ВСЕ решения
class OrderApplicationService:
def cancel(self, order_id: int, actor_id: int) -> None:
order = self.session.get(Order, order_id)
if order.status == "shipped":
customer = self.session.get(Customer, order.customer_id)
if customer.tier != "premium":
raise Forbidden("нельзя отменить отгруженный заказ")
if datetime.now(UTC) - order.shipped_at > timedelta(minutes=30):
raise Forbidden("окно отмены истекло")
order.set_status("cancelled") # ← сеттер под другим именем
self.bonus_service.unfreeze(order.customer_id, order.total)
self.session.commit()
Формально всё есть: слои, «сервис приложения», «доменная сущность». Фактически Order —
структура данных, а вся модель предметной области живёт в if-ах сервиса. Ровно тот же код
получился бы без единого упоминания DDD, только на 300 строк короче.
3.2. Правило, по которому легко проверять
Если снаружи агрегата есть
if, зависящий от двух и более его полей, — этотifстоит не там.
if order.status == "shipped" and customer.tier != "premium" читает status и shipped_at
из заказа — значит, решение принадлежит заказу:
# domain/order.py — теперь без единого импорта инфраструктуры
CANCEL_WINDOW = timedelta(minutes=30)
@dataclass
class Order:
id: OrderId
status: OrderStatus
shipped_at: datetime | None
_lines: list[OrderLine]
def cancel(self, by: Customer, now: datetime) -> OrderCancelled:
"""Единственное законное место, где заказ становится отменённым."""
if self.status is OrderStatus.CANCELLED:
raise AlreadyCancelled(self.id) # идемпотентность решает вызывающий
if self.status is OrderStatus.SHIPPED:
if not by.is_premium:
raise CancellationForbidden("заказ уже отгружен")
if now - self.shipped_at > CANCEL_WINDOW:
raise CancellationForbidden("окно отмены 30 минут истекло")
self.status = OrderStatus.CANCELLED
return OrderCancelled(order_id=self.id, refund=self.total(), by=by.id)
Сценарий приложения теперь скучный — и это правильный признак:
class CancelOrderHandler:
def handle(self, cmd: CancelOrder) -> None:
with self.uow:
order = self.uow.orders.get(cmd.order_id)
customer = self.uow.customers.get(order.customer_id)
event = order.cancel(by=customer, now=self.clock.now())
self.uow.orders.save(order)
self.uow.outbox.add(event) # см. статью про события
self.uow.commit()
3.3. Выход: автоматизируйте границу, иначе она сгниёт
Ревью не удерживает архитектурные границы — люди устают. Границу удерживает CI. Для Python это import-linter:
# setup.cfg
[importlinter]
root_package = shop
[importlinter:contract:layers]
name = Слои: домен ничего не знает о внешнем мире
type = layers
layers =
shop.api
shop.application
shop.domain
[importlinter:contract:domain-purity]
name = Домен не импортирует инфраструктуру
type = forbidden
source_modules = shop.domain
forbidden_modules =
sqlalchemy
fastapi
redis
requests
Аналоги в других экосистемах: ArchUnit для Java/Kotlin,
NetArchTest и ArchUnitNET
для .NET (см. трек C#),
dependency-cruiser для TypeScript,
go list -deps + go-arch-lint для
Go.
Один контракт в CI ловит больше протечек, чем полгода ревью-комментариев «а это точно должно быть в домене?».
4. Уровень границ: контексты нарезаны по данным
4.1. Признак
Названия ваших контекстов (или микросервисов) — это существительные из схемы БД:
user-service, order-service, product-service. Ни один из них не соответствует
чему-то, что бизнес называет своим словом. Спросите продакта: «кто отвечает за оформление
заказа?» — если ответ содержит три сервиса, границы неверны.
4.2. Механика: почему это дороже монолита
Нарезка по сущностям гарантирует, что любой бизнес-сценарий пересекает границы. Последствия измеримы:
- Доступность падает мультипликативно. Четыре сервиса по 99,9% на синхронном пути дают 0,999⁴ ≈ 99,6% — это 3,5 часа недоступности в месяц вместо 43 минут.
- Латентность складывается с хвостами. p99 сценария ≈ сумма p99 звеньев, а не сумма средних: 4 звена по 50 мс p99 дают ~200 мс p99, и это без ретраев.
- Релиз перестаёт быть локальным. Добавление поля в заказ требует согласованного выката четырёх команд — то есть той самой связности, ради ухода от которой сервисы и резали.
Это классический распределённый монолит: связность монолита плюс операционная стоимость распределённой системы. Ни одного плюса, оба минуса.
4.3. Выход
Критерий границы — не данные, а язык и автономность решения:
Контекст правильный, если внутри него слово значит ровно одно и типовой сценарий завершается без синхронных походов наружу.
Практические шаги:
- Проведите Event Storming и ищите разрывы в языке — места, где одно слово («заказ», «клиент», «товар») меняет смысл. Разрыв языка — кандидат в границу.
- Разрешите дублирование данных. «Клиент» в оформлении заказа (адрес, тир, лимит) и «клиент» в поддержке (история обращений, тональность) — это два разных объекта, а не один, размазанный по сервисам. Дублирование полей — цена автономности, и она обычно дешевле сетевого вызова.
- Переведите оставшиеся связи на события вместо вызовов — детали в статье про интеграцию.
- Не режьте сервисы, пока не уверены в границах. Модульный монолит с теми же границами стоит на порядок дешевле и позволяет двигать границу за час, а не за квартал. Это прямая рекомендация Фаулера: MonolithFirst.
4.4. Родственная ошибка: общая база как «интеграция»
Два контекста ходят в одни таблицы — «так же быстрее, чем через API». Это не интеграция,
а отсутствие границы: схема БД становится публичным контрактом, который никто не версионирует.
Через год ни один ALTER TABLE невозможно провести, не найдя всех читателей — а найти их нельзя,
потому что SQL пишется в рантайме.
Столь же вредна «общая доменная библиотека», куда сложили все сущности: это Shared Kernel, который по канону должен быть минимальным и совместно владеемым, а на практике превращается в глобальную зависимость с очередью на изменение.
5. Уровень модели: агрегат размером со схему БД
5.1. Признак
У вас есть Customer, внутри которого коллекция заказов, внутри каждого — позиции, платежи,
доставки. Загрузка клиента тянет 40 тысяч строк. Любое изменение чего угодно конфликтует
с любым другим изменением.
Причина почти всегда одна: агрегат срисовали с ER-диаграммы. Но связь «один-ко-многим» в базе — это про навигацию, а граница агрегата — про транзакционную согласованность. Это разные вопросы.
5.2. Механика конкуренции
Оптимистичная блокировка (статья 04) защищает агрегат целиком. Чем крупнее агрегат, тем выше шанс, что два независимых действия столкнутся на одной версии:
в заказе #900 Note right of S: добавляет комментарий
к заказу #113 M->>DB: UPDATE ... WHERE version=7 → version=8 DB-->>M: 1 строка, OK S->>DB: UPDATE ... WHERE version=7 DB-->>S: 0 строк Note over S,DB: конфликт версий — хотя действия
касались разных заказов S->>DB: перечитать и повторить Note over S: при высокой нагрузке — livelock:
ретраи конфликтуют снова
Численно: если агрегат обслуживает λ записей в секунду, а транзакция длится T, вероятность конфликта растёт примерно как 1 − e^(−λT). При λ = 20 rps и T = 50 мс это ≈ 63% — система тратит больше времени на ретраи, чем на работу. Стоимость чтения при этом O(число дочерних сущностей), то есть загрузка «толстого» клиента линейна по всей его истории — и эта история только растёт.
5.3. Выход: правила Вернона
Vaughn Vernon, Effective Aggregate Design — три PDF, которые стоит прочитать целиком. Короткая версия:
- Моделируйте истинные инварианты внутри границы. Если правило звучит «сумма позиций не превышает лимит заказа» — позиции внутри заказа. Если «у клиента не больше 5 активных заказов» — это, скорее всего, правило, которое можно проверить с eventual consistency.
- Проектируйте маленькие агрегаты. По умолчанию — корень плюс объекты-значения.
- Ссылайтесь на другие агрегаты по идентификатору, а не по объекту.
order.customer_id, а неorder.customer. Это физически запрещает случайный обход границы. - Обновляйте другие агрегаты через события с eventual consistency.
# было: одна транзакция на два агрегата — граница не значит ничего
def place_order(self, cmd):
with self.uow:
order = Order.place(cmd.lines)
stock = self.uow.stock.get(cmd.sku)
stock.reserve(cmd.qty) # ← второй агрегат в той же транзакции
self.uow.orders.add(order)
self.uow.commit()
# стало: одна транзакция — один агрегат, связь через событие
def place_order(self, cmd):
with self.uow:
order = Order.place(cmd.lines) # агрегат сам решает, что он валиден
self.uow.orders.add(order)
self.uow.outbox.add(OrderPlaced(order.id, order.lines))
self.uow.commit() # событие и состояние — атомарно
# отдельный обработчик, отдельная транзакция, свой агрегат
def on_order_placed(self, event: OrderPlaced):
with self.uow:
stock = self.uow.stock.get(event.sku)
try:
stock.reserve(event.qty)
except OutOfStock:
self.uow.outbox.add(ReservationFailed(event.order_id)) # компенсация
self.uow.commit()
Возражение «а если резерв не пройдёт?» — правильное, и ответ на него: значит, бизнес должен описать компенсацию. Не «база откатит», а «клиент получит письмо, что позиция закончилась». Это разговор с продактом, а не с ORM. Если бизнес говорит «такого не должно происходить никогда» — вот тогда это один агрегат.
6. Тактические ловушки помельче
6.1. Репозиторий, который на самом деле DAO
# антипаттерн: репозиторий протекает наружу деталями хранения
class OrderRepository:
def query(self) -> Query: ... # ← отдал ORM-Query наружу
def find_by_status_and_date_and_tier(...): ... # ← 40 методов «под каждый экран»
def update_status(self, id, status): ... # ← пишет мимо агрегата
Признаки: репозиторий возвращает Query/IQueryable/QuerySet; у него больше 5–7 методов;
есть методы, меняющие поля в обход агрегата. Первый — фатальный: сценарий начинает конструировать
запросы, то есть знать про схему.
Выход — разделить чтение и запись. Репозиторий отдаёт агрегаты целиком и умеет get/save/
пару доменных выборок. Экранам нужны не агрегаты, а плоские проекции — им делают отдельный
read-модуль с прямым SQL. Это лёгкий CQRS без событийного стора и он окупается почти всегда:
# application/queries/order_list.py — никакого домена, только SQL и DTO
def list_orders(conn, customer_id: int, limit: int = 50) -> list[OrderListItem]:
rows = conn.execute(text("""
SELECT o.id, o.status, o.placed_at, SUM(l.price * l.qty) AS total
FROM orders o JOIN order_lines l ON l.order_id = o.id
WHERE o.customer_id = :cid
GROUP BY o.id ORDER BY o.placed_at DESC LIMIT :lim
"""), {"cid": customer_id, "lim": limit})
return [OrderListItem(**r._mapping) for r in rows]
6.2. События как CRUD-нотификации
# бессмысленное событие: подписчик обязан догадаться, что произошло
@dataclass
class OrderUpdated:
order_id: int
changed_fields: dict[str, Any]
# осмысленное: имя на языке бизнеса, полезная нагрузка достаточна для реакции
@dataclass(frozen=True)
class OrderCancelled:
order_id: OrderId
cancelled_by: ActorId
reason: CancellationReason
refund_due: Money
occurred_at: datetime
OrderUpdated заставляет каждого подписчика реконструировать намерение по diff-у полей —
то есть знать чужую модель. Это связность худшего сорта: невидимая в типах и всплывающая
в проде. Проверочный вопрос к имени события: может ли бизнес-эксперт объяснить его без слова
«поле»? Если нет — событие техническое, и в интеграционном контракте ему не место.
Ещё одна ошибка: публиковать доменные события наружу как есть. Доменное событие — внутренний язык контекста, оно меняется вместе с моделью. Наружу должен идти отдельный, стабильный, версионируемый интеграционный контракт. Подробности — в статье 05.
6.3. Оверинжиниринг тактикой
Обратная крайность, встречается у команд, которые уже прочитали книгу:
- Объект-значение на каждый примитив.
CustomerName,Description,Comment— три класса по 20 строк, оборачивающиеstrбез единого правила. VO оправдан, когда есть поведение или инвариант:Money(арифметика + валюта),Email(нормализация),DateRange(пересечения).Comment(value: str)— накладные расходы без выгоды. - Доменный сервис на всё.
OrderValidationService,OrderCalculationService— это возврат к анемичной модели через чёрный ход. Доменный сервис оправдан, когда операция не принадлежит ни одному агрегату (перевод между счетами, подбор тарифа по нескольким источникам). - CQRS + Event Sourcing «потому что DDD». Event Sourcing — самостоятельное решение с самостоятельной ценой: проекции, версионирование событий, ретроактивные исправления, отладка. Он оправдан, когда бизнесу нужна история как факт (аудит, финансы, комплаенс), и не оправдан «чтобы можно было восстановить состояние». Отличный разбор компромиссов — Event Sourcing у Фаулера и критика от Кориковa.
7. Уровень людей: где ломается больше всего
Технические ошибки чинятся кодом. Эти — нет.
7.1. Единый язык, которого нет
Признак: есть страница в Confluence «Глоссарий проекта», последнее изменение — 14 месяцев назад,
а в коде встречаются OrderDTO, OrderEntity, OrderModel, OrderData — и это четыре разные вещи,
про которые никто не может сказать, чем они отличаются.
Механика: единый язык — не словарь, а побочный продукт регулярного разговора. Он живёт,
пока разработчики и эксперты решают задачи вместе и вслух. Глоссарий фиксирует результат,
но не может его создать. Как только эксперт превращается в источник тикетов, язык расслаивается:
бизнес говорит «списание», разработчики пишут transaction_type=2, аналитик рисует «выбытие» —
и каждое уточнение требования проходит два перевода с потерями.
Выход: язык проверяется не документом, а тестом. Возьмите файл тестов вашего ядра и покажите эксперту. Если он не может прочитать названия тестов и сказать «да, так и работает» — языка нет. Отсюда практика: тесты домена именуются фразами бизнеса.
def test_повторный_пробный_период_не_выдается_после_отмены(): ...
def test_смена_плана_в_середине_периода_не_сдвигает_дату_списания(): ...
def test_просроченная_подписка_блокируется_через_7_дней(): ...
Выглядит непривычно, работает безотказно: это единственный артефакт, который одновременно исполняется и читается бизнесом.
7.2. Контекст без владельца
Закон Конвея работает в обе стороны. Если ограниченный контекст не совпадает с зоной ответственности одной команды, границу будут нарушать — не из вредности, а потому что дедлайн. Контекст, который правят три команды, за полгода превращается в общий Big Ball of Mud внутри красивых папок.
Правило: один контекст — одна команда-владелец (одна команда может владеть несколькими контекстами, обратное — нет). Это ядро Team Topologies и обратного манёвра Конвея.
7.3. DDD-полиция и ритуалы
Симптом зрелой болезни: в команде появляется человек, который на ревью пишет «это должно быть объектом-значением», не объясняя, какой инвариант это защищает. Дальше — карго-культ: правила соблюдаются, потому что правила, а не потому что дешевле.
Лечится одним вопросом на ревью: «какое изменение станет дешевле, если мы это сделаем?» Нет внятного ответа — нет изменения. DDD — экономическая дисциплина, а не эстетическая.
7.4. Big bang: переписать всё на DDD
Самый дорогой способ провалиться. Полтора года команда пишет «правильную» систему, продукт всё это время живёт на старой, требования расходятся, и в момент переключения выясняется, что новая система реализует требования позапрошлого года.
Выход — Strangler Fig: новый контекст живёт рядом со старой системой за фасадом, забирает по одному сценарию, старый код удаляется по мере освобождения. Каждый шаг ценен сам по себе и обратим.
Антикоррупционный слой (ACL) здесь не формальность: он не даёт модели легаси просочиться в новый контекст. Без него через полгода новый контекст говорит на языке старых таблиц, и вы получили ту же систему с новыми папками.
8. Что чинить первым
Не все ошибки равны. Приоритизируйте по двум осям: как часто встречается и сколько стоит исправление позже, чем сейчас (границы дорожают быстрее всего — они застывают вместе с контрактами и организационной структурой).
Читается так: анемичная модель встречается почти везде, но чинится инкрементально — агрегат за агрегатом, без остановки продукта. А вот границы и владение переносить позже действительно дорого: каждый месяц отсрочки добавляет интеграций, которые придётся рвать.
9. Диагностика: от симптома к диагнозу
Практический чек-лист. Проходится за час на любом проекте.
менялось более чем
в 1 файле?} B -- да --> C[Логика размазана:
искать анемичную модель] B -- нет --> D{Сценарий требует
синхронных вызовов
в чужой сервис?} D -- да --> E[Границы неверны:
распределённый монолит] D -- нет --> F{Есть ли конфликты
версий/дедлоки
в логах?} F -- да --> G[Агрегат слишком крупный:
резать по инвариантам] F -- нет --> H{Бизнес-эксперт понимает
названия тестов домена?} H -- нет --> I[Единого языка нет:
вернуть эксперта в цикл] H -- да --> J{Домен вообще есть?
Считаем инварианты} J -- менее 5 --> K[Возможно, DDD лишний:
упростить до CRUD] J -- много --> L[Здоровая модель:
следить за метриками] C --> M[Перенести if-ы внутрь агрегата,
закрепить контрактом в CI] E --> N[Модульный монолит,
события вместо вызовов] G --> O[Правила Вернона:
ссылки по ID, eventual consistency] I --> P[Event Storming,
тесты на языке бизнеса]
Таблица «симптом → диагноз → первое действие»
| Симптом | Диагноз | Первое действие |
|---|---|---|
| В PR правится 3+ файла ради одного правила | Логика размазана | Найти агрегат-владельца правила, перенести if |
В domain/ есть import sqlalchemy |
Протечка инфраструктуры | Контракт в import-linter, маппинг наружу |
Репозиторий возвращает Query/IQueryable |
Репозиторий = DAO | Read-модель отдельно, репозиторий отдаёт агрегаты |
| Имена сервисов = имена таблиц | Границы по данным | Event Storming, искать разрывы языка |
| Два сервиса пишут в одну таблицу | Границы нет | Назначить владельца таблицы, остальным — API/события |
В логах OptimisticLockError пачками |
Агрегат-гигант | Резать по инвариантам, ссылки по ID |
События называются *Updated, *Changed |
CRUD-нотификации | Переименовать по намерению, обогатить payload |
| Глоссарий не менялся год | Языка нет | Вернуть эксперта в цикл разработки |
| Три команды правят один контекст | Нет владельца | Обратный манёвр Конвея |
| Проект «переписываем на DDD» идёт 9+ месяцев без релиза | Big bang | Strangler Fig, первый сценарий в прод за 6 недель |
Метрики здоровья модели
Ставятся один раз, дальше следят автоматически:
- Разброс изменения правила — среднее число файлов в PR, помеченном
feat(domain). Здоровый диапазон: 1–2. Рост означает возврат к размазанной логике. - Число нарушений архитектурных контрактов в CI. Целевое значение — ноль; допущенные исключения фиксируются в конфиге со сроком.
- Доля сценариев с синхронными межконтекстными вызовами. Растёт — границы поплыли.
- Частота
OptimisticLockErrorна 1000 команд. Индикатор размера агрегата. - Покрытие домена быстрыми тестами без БД. Если доменные тесты требуют Postgres, домен не изолирован — и всё остальное в этой статье уже неважно.
10. Мини-итог
- Провал DDD почти никогда не технический. Чаще всего это DDD не там (CRUD-домен) или DDD не то (папки вместо модели).
- Самый массовый антипаттерн — анемичная модель в DDD-обёртке. Проверка:
if, читающий два поля агрегата снаружи агрегата. - Самый дорогой — неверные границы: нарезка по таблицам даёт распределённый монолит, где падает доступность, растёт латентность и релиз требует четырёх команд.
- Агрегат — граница транзакционной согласованности, а не поддерево ER-диаграммы. Маленькие агрегаты, ссылки по ID, eventual consistency между ними.
- Границы держит CI, а не ревью. Язык держит разговор с экспертом, а не глоссарий. Контекст держит одна команда-владелец.
- Внедряйте инкрементально: Strangler Fig, ACL, dual-run. Big bang проваливается почти всегда.
- И главный вопрос на любом ревью: «какое изменение станет дешевле, если мы это сделаем?»
Источники
- Eric Evans. Domain-Driven Design (2003) — часть IV про стратегию; главы 14–17 прямо про то, как границы разрушаются под давлением.
- Vaughn Vernon. Effective Aggregate Design — три PDF, лучший разбор ошибок размера агрегата.
- Martin Fowler. Anemic Domain Model, MonolithFirst, Strangler Fig Application, Microservice Premium.
- Sam Newman. Monolith to Microservices (2019) — глава 3 про декомпозицию по возможностям и про то, почему нарезка по данным даёт распределённый монолит.
- M. Skelton, M. Pais. Team Topologies — обратный манёвр Конвея и владение контекстами.
- Vladimir Khorikov. DDD и оверинжиниринг, Event Sourcing как антипаттерн.
- H. Percival, B. Gregory. Architecture Patterns with Python — главы про Unit of Work и события, с честным обсуждением, когда всё это лишнее.
- Chris Richardson. Microservices Patterns / antipatterns.
- Инструменты: import-linter, ArchUnit, dependency-cruiser.
Что дальше
Трек по Domain-Driven Design закончен. Вы прошли путь от вопроса «зачем вообще» через стратегию и границы к работающему коду и к каталогу ошибок — этого достаточно, чтобы вести внедрение самостоятельно и вовремя останавливаться там, где оно не нужно.
Куда двигаться дальше, в зависимости от того, что сейчас болит:
- Архитектура систем в целом — DDD задаёт границы, но не отвечает, как их разворачивать и эксплуатировать: архитектурные паттерны, а заодно принципы проектирования и паттерны проектирования — тактические блоки DDD стоят ровно на них.
- Реализация на конкретном стеке — Go, C#, TypeScript или Elixir: у каждого языка своя цена на те же конструкции, и это заметно меняет, что стоит делать явно, а что нет.
- Фундамент — алгоритмы и структуры данных: read-модели, проекции и производительность агрегатов упираются именно туда.
- Работа с людьми и требованиями — треки по продуктовому и проектному управлению: большинство разобранных здесь провалов начинается задолго до первой строки кода.
Общая карта всех треков портала и рекомендуемый порядок чтения — в дорожной карте.