DDD: не серебряная пуля, а набор компромиссов
Содержание
TL;DR: 80% пользы от DDD – в стратегическом проектировании: bounded contexts, core/supporting/generic, ubiquitous language. Тактические паттерны – опция, не обязательство. Главная ошибка – начинать с Entity и Aggregate на проекте, где ещё не понят домен. Или, хуже того, внедрять DDD на Generic subdomain, который проще купить у провайдера. В статье – фреймворк, как выбирать подход под сложность домена, и чеклист из 10 вопросов, нужен ли DDD вашему проекту.
Пришёл я как-то на архитектурный аудит и увидел: команда решила “сделать правильно с самого начала”. Техлид прочитал Эванса и Вернона, на дейли принёс слайды про bounded contexts и Aggregate Root. Все согласились. Принялись.
Через полгода сервис, который рассылает email, SMS и пуши, выглядел так: агрегат Notification с методом RegisterBounce(BounceReason), восемь Domain Events вида NotificationDispatched → NotificationDelivered → NotificationRetryScheduled, Value Object RetryPolicy с отдельным VO для backoff-стратегии. В Confluence висел слайд с Context Map – кружки, стрелки, ACL. Его показывали на онбординге.
А делал сервис ровно одно: HTTP POST в SendGrid для email, Twilio для SMS и Firebase для пушей – с ретраями при неудаче.

Добавить новый тип уведомления – неделя. Новый разработчик неделю не понимал, куда воткнуть кастомный HTTP-заголовок: в NotificationChannel? в DeliveryPolicy? в адаптер? Интеграционный тест “отправить одно письмо” поднимал шесть классов и мокал четыре интерфейса. Когда провайдер сменил API – PR на сорок файлов, потому что формат message id просочился в два Value Object и поля Domain Events.
Первым проблему озвучил джун. Его бесило, что “добавить кнопку в письмо” – это три дня. Ему ответили: “Ты просто не привык к чистой архитектуре”. Через квартал техлид сам посчитал часы: 20% времени двух разработчиков уходит на поддержку. Переписали на CRUD за три спринта. Триста строк вместо четырёх тысяч пятисот. Онбординг – день.
Я рассказываю это не чтобы поржать над командой. Это были нормальные разработчики, начитавшиеся хороших книг. Ошибка была не в DDD. Ошибка – в том, куда они его применили. И это самая частая ошибка, которую я вижу.
В предыдущих статьях разбирались с фрагментацией знаний и предположениями, которые попадают в код вместо знаний экспертов. Event Storming был первым инструментом, который эти предположения вытаскивает на стену. DDD – следующий слой: язык, на котором обсуждают границы, приоритеты и общие понятия. Агрегаты с репозиториями – это уже инструменты внутри, и далеко не всегда нужные.
DDD за пять минут: суть без воды
Большинство статей про DDD начинаются с определений Эванса, Aggregate Root и Entity vs Value Object. Это неправильный вход. DDD – не про классы. DDD – про организацию мышления при работе со сложным доменом.
У подхода две части.
Strategic Design отвечает на вопрос “что делаем?” Это работа на уровне доменов, поддоменов, границ и общего языка. Стратегические паттерны – это:
- Domain / Subdomain – из чего состоит бизнес и какие у него подобласти
- Core / Supporting / Generic – где конкурентное преимущество, где – инфраструктура
- Ubiquitous Language – общий словарь команды и экспертов
- Bounded Context – граница, внутри которой модель однозначна
- Context Map – как контексты связаны между собой
Tactical Design отвечает на вопрос “как именно делаем?” Это инструменты для разработчика внутри bounded context – Entity, Value Object, Aggregate, Domain Event, Repository, Domain Service, Factory.
Ключевой тезис, который я редко встречаю сформулированным прямо: 80% пользы от DDD – в стратегической части. Тактическая – опция.
Strategic Design окупается быстро – если домен нетривиален и команда больше двух-трёх человек. На маленьком CRUD и он избыточен. Но там, где сложность есть, понимание, что Заказ в контексте бронирования и Заказ в контексте оплаты – это две разные модели с разными жизненными циклами, спасает от половины архитектурных проблем. Ubiquitous Language – дешёвая инженерная привычка, окупается даже без остального DDD. Без неё разработчики и бизнес говорят на разных языках, а потом удивляются, что ТЗ интерпретируется тремя способами.
Tactical Design – совсем другой разговор. Эти паттерны окупаются, только когда доменная логика сама этого требует: когда у вас инварианты, которые нельзя нарушить в рамках одной транзакции, когда поведение привязано к данным, когда бизнес-правила часто меняются. На CRUD-приложении, где 80% логики – это “прочитать, отредактировать, сохранить”, тактические паттерны только накручивают сложность.
Отсюда первое практическое правило: начинайте со Strategic, а Tactical – по необходимости. Обратный порядок – как в истории про нотификации. Я сам, когда впервые прочитал Эванса, тоже начал с Aggregate и Repository – мне казалось, что это и есть DDD. Стратегическую часть я пролистал как “слишком абстрактную”. Понадобился год и два переписанных модуля, чтобы понять, что пролистал самое ценное.
Transaction Script vs Domain Model: как выбрать под сложность
Мартин Фаулер в PoEAA выделил три подхода к организации бизнес-логики. Имена звучат академично, но сами подходы – это то, чем все мы пишем, просто не всегда осознанно.
Transaction Script. Бизнес-логика – это набор процедур. Каждая обрабатывает один сценарий от начала до конца: получить вход, сходить в базу, что-то посчитать, записать обратно, вернуть ответ. Весь код одного сценария – в одной функции или сервисе.
Хорош для простых доменов и стартап-стадии. Плох, когда логика начинает повторяться между сценариями: ты либо копипастишь, либо придумываешь утилиты, которые со временем превращаются в HelperService на две тысячи строк.
Table Module / Active Record. Один класс на таблицу. Логика – методы этого класса. Это то, что получается естественно, если начинать проектирование со схемы БД (я сам так начинал каждый новый проект лет 15 назад). Работает – пока таблица соответствует одной бизнес-концепции. Когда перестаёт – начинаются god-объекты с двадцатью полями и сорока методами.
Domain Model. Код моделирует не таблицы, а саму предметную область. Объекты имеют поведение, а не только данные. Правила бизнеса живут на доменных объектах, а не в сервисах-оркестраторах. DDD подталкивает именно к этому – но сама модель существует независимо от DDD и появилась раньше.
Фаулер привёл график, который я показываю на каждой архитектурной встрече, где обсуждают “выбор архитектуры”. По оси X – сложность доменной логики. По оси Y – усилия на разработку и поддержку.
Кривая Transaction Script стартует низко и растёт линейно, потом экспоненциально. Domain Model стартует выше (начальная инвестиция дороже), но растёт почти линейно. Table Module – посередине.
Точка пересечения Transaction Script и Domain Model – это место, где упрощённый подход уже дороже сложного. До этой точки DDD – overkill. После – экономия. Проблема в том, что точку эту никто не видит заранее. Её видно только по хвосту: “почему у нас такая стоимость изменений?” – и это уже наш первый разговор.
Практический вывод, который я для себя сделал: подход не выбирается раз и навсегда. В одном проекте нормально сосуществуют все три. Core пишите в Domain Model, Supporting – в Transaction Script или Table Module, Generic – вообще не пишите, берите готовое.
Core / Supporting / Generic: фреймворк “куда вкладывать мозги”
Этот фреймворк – самый недооценённый инструмент в DDD. Я видел, как команды годами спорят про агрегаты, ни разу не задав себе вопрос: “а в этой части продукта вообще есть смысл писать код своими руками?”
Бизнес делится на поддомены. Поддомены делятся на три типа.
Core domain – то, ради чего бизнес существует. Конкурентное преимущество. То, что отличает компанию от других. На travel-платформе, где я работал, Core – это TripPolicy: система правил, по которым корпоративные клиенты согласовывают поездки. Там были пороги автосогласования (до 40 тысяч – автоматом, выше – ручной аппрув), разные классы обслуживания по грейду сотрудника, исключения для VIP. Конкуренты не могли это скопировать, потому что правила жили в головах нескольких менеджеров и росли органически годами.
Core пишут сами, вкладываются в качество, в язык, в общение с бизнесом. Сюда – лучших разработчиков, рефакторинг, тесты, документацию, бюджет.
Supporting subdomain – то, что помогает бизнесу зарабатывать, но не является его сутью. CRM, работа с заказами, маркетинг, личный кабинет. Эти вещи сами по себе не выделяют компанию на рынке, но без них компания не работает.
Supporting чаще всего пишут сами, но без фанатизма. Достаточно нормального качества – не надо украшать каждый класс паттернами. Transaction Script или Table Module – норм.
Generic subdomain – то, что не даёт никакого преимущества и является чьим-то Core. Авторизация, email-рассылка, биллинг, файловое хранилище, логирование, поиск по адресам. Для вашего бизнеса это плюшка; для Okta, SendGrid, Stripe, S3, ElasticSearch, Dadata – это смысл жизни, и они делают это на порядок лучше, чем вы когда-либо сделаете.
Generic – покупают. Ставят готовое. Интегрируются. Пишут тонкий клиент. Не пишут DDD.
Вернёмся к истории с нотификациями. Что сделала команда? Взяла Generic subdomain и выделила под него лучшие инженерные силы. Написала Aggregate Root, Domain Events, Context Map – для сервиса, который тоньше API-обёртки над SendGrid. Вложила мозги туда, где никакого конкурентного преимущества не возникнет даже при идеальной реализации.
Анти-паттерн, который встречается чаще, чем кажется: лучшие разработчики на Generic. Они делают Generic идеально, а Core пишут джуны между делом. Через год компания не отличается от конкурентов ничем, зато у неё самая красивая система рассылки писем в индустрии.
Эту таксономию я использую при первом же разговоре про архитектуру на новом проекте. Простые вопросы:
- Что отличает нас от конкурентов? – это Core
- Что мы делаем сами, но это не наш бизнес? – Supporting
- Что мы можем купить или взять готовое? – Generic
Коротко, в одной таблице:
| Тип поддомена | Что с ним делать | Какой подход | Кто пишет |
|---|---|---|---|
| Core | Писать сами, вкладываться в качество | Domain Model, возможно DDD-тактика | Лучшие разработчики |
| Supporting | Писать сами, без фанатизма | Transaction Script или Table Module | Обычная команда |
| Generic | Не писать – покупать или open-source | Тонкий клиент поверх провайдера | Минимум усилий |
Если на первый вопрос команда не может ответить, или отвечает “у нас всё – Core” – это, как правило, сигнал, что бизнес сам не знает, в чём его преимущество. Тогда разговор выходит за рамки архитектуры. Но это уже не моя проблема как разработчика – хотя иногда очень даже моя.
Bounded Context: самый недопонятый паттерн DDD
Если вы запомните из этой статьи только один паттерн – пусть это будет Bounded Context.
Определение Эванса звучит сухо: граница, в пределах которой модель предметной области имеет одно конкретное значение. Переведу на человеческий: внутри bounded context одно слово означает одну вещь. Снаружи – может означать другую.
Любимый пример.
Слово “Заказ”. Одно и то же в речи, код пишется одинаково: class Order. Но:
- В такси:
Order– это маршрут от точки А до точки Б, жизненный цикл короткий (минуты), важны координаты, статус водителя, стоимость посчитана по алгоритму. - В ресторане:
Order– это список блюд на столик, жизненный цикл средний (час), важны позиции меню, статус готовки каждой позиции отдельно, возможность добавить в середине. - В логистике:
Order– это договор на перевозку груза, жизненный цикл долгий (дни-недели), важны документы, вес, страховка, цепочка перевозчиков.
Одно слово, три разных модели – с разными инвариантами и жизненными циклами. Попытка сделать один класс Order “на все случаи” – корень половины проблем в больших системах.
Или, ещё тоньше, случай из моего опыта. На travel-платформе было слово “Booking”. Для фронт-команды – это заявка пользователя, которую можно менять до оплаты. Для back-команды, работающей с провайдерами, – это уже зафиксированный у поставщика ресурс (отель/билет), который менять стоит денег. Для биллинга – это операция начисления, привязанная к расчётному периоду.
Узнал я это на ревью одного PR. Фронтендер написал метод CancelBooking(), а бэкендер на ревью спросил: “Подожди, ты отменяешь заявку пользователя или бронь у поставщика? Потому что второе стоит денег.” Оказалось, оба были правы – просто говорили о разных вещах одним словом.
Пока “Booking” жил в общих каналах, каждый слышал своё. Попытки сделать “общую модель” превращали любой рефакторинг в трёхстороннюю войну.
Помогло то, что мы стали явно разделять: в фронте это Reservation, у back-бека – ProviderBooking, в биллинге – BookingCharge. Три слова вместо одного. Код стал понятнее, ТЗ – читаемее. Мы не пришли к этому через “давайте применим DDD”. Мы пришли к этому через боль. DDD – это просто язык, на котором эту боль можно описать и предотвратить в следующий раз.
Context Map: круги и стрелки
Когда bounded context’ов несколько, они связаны. Context Map – это диаграмма, где контексты изображены кругами, а связи – стрелками с типом интеграции (Customer/Supplier, Conformist, Anti-Corruption Layer и т. д.).
Не буду перечислять все паттерны интеграции – в книгах Эванса и Вернона это расписано лучше. Скажу практическое: минимальный Context Map полезнее максимального.
Достаточно нарисовать круги (с именами контекстов) и стрелки “кто от кого зависит” на одном листе A4. Этого хватает, чтобы команда увидела вещи, которые в коде не видны:
- Какие контексты слишком связаны и должны быть объединены
- Где у вас неявный distributed monolith – два “сервиса”, которые не могут деплоиться независимо
- Где отсутствует Anti-Corruption Layer и изменение в одном контексте каждый раз ломает другой
Я рисую Context Map на стикерах на стене в первый же день работы на новом проекте. Часто оказывается, что у команды нет общего представления, из скольких контекстов состоит их собственная система. Это само по себе находка.
Конкретный пример из travel-платформы. Нарисовали карту – увидели, что два “независимых” сервиса (Inventory и Catalog, которые хранили данные по отелям и показывали их в поиске) на самом деле один bounded context. Между ними не было никакого ACL – только прямые вызовы по внутреннему API, деплоились они всегда вместе, и любое изменение модели в одном требовало изменения во втором в том же релизе. На бумаге – два микросервиса. По факту – один distributed monolith с лишним сетевым хопом. Объединили – пропала половина проблем с рассинхронизацией данных в поиске.
Типичные ошибки с BC
Один BC на весь проект. Монолит с единственной “доменной моделью”. В ней User – это и клиент, и админ, и контрагент, и получатель рассылки. Рано или поздно он разрастается в god-объект, а изменения в одной части ломают всё остальное.
Слишком мелкие BC. “У нас микросервисы, у нас DDD”. Один bounded context на микросервис, микросервисы зависят друг от друга через API, одна бизнес-транзакция проходит через четыре сервиса. Получается distributed monolith – деплоить нельзя независимо, тестировать надо все четыре вместе, а задержки складываются. Границы BC должны определяться по смыслу, а не по размеру команды или моде на микросервисы.
Bounded context как неймспейс. Назвали папки в проекте Booking.Domain, Payment.Domain, считаем, что провели границы. А классы из одной папки всё равно напрямую ссылаются на классы из другой. Физически границы нет, есть только ярлык. Это не bounded context, это директория.
Граница настоящая, когда:
- Команды могут работать над своими контекстами независимо
- Изменение внутри контекста не ломает другие
- Модель одного контекста недоступна напрямую из другого – только через явный интеграционный слой
Когда DDD – overkill: честный разговор
Теперь самое важное и самое редко обсуждаемое. Когда DDD не нужен.
DDD родился из работы со сложными доменами – страховка, финансы, логистика. Там, где бизнес-правил больше, чем технических решений. Применять его к проекту, где технических решений больше, чем бизнес-правил, – это как использовать промышленный станок для нарезки хлеба дома.
Вот признаки, что полный DDD вам не нужен:
- Домен – CRUD. 80% операций: прочитать, отредактировать, сохранить. Бизнес-логика умещается в валидацию полей.
- MVP или ранний стартап. Вы ещё не знаете, какой у вас домен. Внедрять DDD до того, как домен стабилизировался, – значит закрепить в коде неправильную модель и потом переделывать.
- Простой домен, понятный всем. Все участники команды одинаково понимают термины. Новый разработчик разбирается в домене за день. Ubiquitous Language уже есть “бесплатно”.
- Команда из двух-трёх человек. Знание домена не фрагментируется автоматически – обмен происходит сам собой.
- Проект живёт от силы полгода-год. Долгосрочная инвестиция в архитектуру не окупается на коротких горизонтах.
- Generic subdomain. Вне зависимости от размера – тут просто не надо писать DDD.
А вот признаки, что Strategic DDD имеет смысл:
- Домен сложный, эксперты нужны для проектирования
- Команда больше 5-7 человек, начинается фрагментация знаний
- Разные части системы называют одинаковые вещи по-разному (или разные вещи – одинаково)
- Проект живёт годы и будет развиваться
- Есть явный Core domain, и команда хочет его защищать
Tactical DDD – ещё более узкая история. Она стоит того, когда:
- В Core domain много инвариантов, которые трудно выразить в процедурном коде
- Бизнес-правила часто меняются, и вам нужен код, который эти изменения выдерживает
- Команда уже работает в Ubiquitous Language, и хочется, чтобы код его отражал
Во всех остальных случаях тактические паттерны стоят дороже, чем дают. Обучение команды, поддержка абстракций, замедление итераций – всё это окупается только там, где сложность домена реально их требует.
Чеклист: нужен ли вам DDD
Десять вопросов. Чем больше “да” – тем больше DDD имеет смысл.
- Проект будет жить больше 2 лет?
- В команде больше 5 человек (включая бизнес)?
- У проекта есть явный Core domain – то, что отличает вас от конкурентов?
- Доменные правила часто меняются и будут меняться дальше?
- Разные команды/отделы используют одни и те же термины для разных вещей?
- У вас в коде уже есть god-объекты или “универсальные модели”?
- Рефакторинг одной фичи регулярно ломает другие, не связанные с ней?
- Стоимость добавления новых фич растёт быстрее, чем росла команда?
- Вы нанимаете доменных экспертов или уже работаете с ними?
- Бизнес готов тратить время на совместное моделирование?
Если “да” меньше трёх – DDD вам, скорее всего, не нужен. Возьмите Ubiquitous Language (это дёшево и обычно окупается) и идите писать Transaction Script.
От трёх до шести – Strategic DDD окупится. Bounded contexts, Core/Supporting/Generic, Context Map. Tactical – по необходимости, точечно, где действительно сложно.
Больше шести – полный DDD имеет смысл, включая тактические паттерны. Хотя если вы там – скорее всего, уже убедились в этом на своей шкуре.
В итоге
DDD – не религия и не серебряная пуля. Набор инструментов, у каждого своя цена и своя отдача. Чтобы окупился – надо понимать, где проходит граница применимости.
Три вещи, которые стоит унести из статьи.
Первое. Strategic Design дёшево внедрить и дорого игнорировать. Ubiquitous Language, bounded contexts, Core/Supporting/Generic – основа, на которой тактические паттерны либо станут полезными, либо просто не понадобятся.
Второе. Не пишите DDD на Generic subdomain. Email-рассылка, авторизация, файловое хранилище – не место для лучших инженерных сил. Покупайте и интегрируйте, не вспоминайте.
И главное. Настоящий вопрос – не “делаем ли мы DDD”, а “понимаем ли мы свой домен”. DDD – это словарь, на котором удобно обсуждать сложность. Если словарь вам пока не нужен – это не проблема. Проблема – делать вид, что нужен, и писать по книжке.
В следующей статье разберём тактические паттерны на конкретном коде: Entity, Value Object, Aggregate, Domain Event. Но с одним условием – мы уже договорились, что применяем их только там, где сложность этого требует.
Если есть свой кейс “DDD, который пошёл не туда” – расскажите в телеграм-канале. На следующей неделе там будет отдельный пост – разбор популярного тейка “DDD – это карго-культ”. С чем согласен, с чем нет.
Что почитать
- Эрик Эванс – “Предметно-ориентированное проектирование: структуризация сложных программных систем”. Первоисточник. Сложный, местами нудный, но без него никуда. Читать частями.
- Вон Вернон – “Реализация методов предметно-ориентированного проектирования”. Более практическая версия Эванса с кодом.
- Влад Хононов – “Learning Domain-Driven Design”. Самая современная и прагматичная из книг по DDD. Короче и понятнее остальных.
- Мартин Фаулер – “Архитектура корпоративных программных приложений” (PoEAA). За определениями Transaction Script / Table Module / Domain Model и за графиком “сложность vs усилия” – туда.
- Скотт Миллетт, Ник Тьюн – “Предметно-ориентированное проектирование: паттерны, принципы и методы”. Хороший мост между стратегией и тактикой, с примерами на .NET.