Перейти к содержимому

DDD: не серебряная пуля, а набор компромиссов

· 16 мин
Проектирование, которое работаетЧасть 4 из 4
Содержание

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 для пушей – с ретраями при неудаче.

Galaxy brain мем: от 'Отправить email' до 'Context Map в Confluence для письма Ваш код: 4829'
Эволюция сервиса нотификаций: от одного HTTP POST до Context Map в Confluence

Добавить новый тип уведомления – неделя. Новый разработчик неделю не понимал, куда воткнуть кастомный 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 vs Table Module vs Domain Model
График Фаулера: Transaction Script дешевле на простом домене, Domain Model — на сложном. Точка пересечения — момент, когда упрощённый подход становится дороже

Точка пересечения 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 имеет смысл.

  1. Проект будет жить больше 2 лет?
  2. В команде больше 5 человек (включая бизнес)?
  3. У проекта есть явный Core domain – то, что отличает вас от конкурентов?
  4. Доменные правила часто меняются и будут меняться дальше?
  5. Разные команды/отделы используют одни и те же термины для разных вещей?
  6. У вас в коде уже есть god-объекты или “универсальные модели”?
  7. Рефакторинг одной фичи регулярно ломает другие, не связанные с ней?
  8. Стоимость добавления новых фич растёт быстрее, чем росла команда?
  9. Вы нанимаете доменных экспертов или уже работаете с ними?
  10. Бизнес готов тратить время на совместное моделирование?

Если “да” меньше трёх – 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 – это карго-культ”. С чем согласен, с чем нет.


Что почитать

  1. Эрик Эванс“Предметно-ориентированное проектирование: структуризация сложных программных систем”. Первоисточник. Сложный, местами нудный, но без него никуда. Читать частями.
  2. Вон Вернон“Реализация методов предметно-ориентированного проектирования”. Более практическая версия Эванса с кодом.
  3. Влад Хононов“Learning Domain-Driven Design”. Самая современная и прагматичная из книг по DDD. Короче и понятнее остальных.
  4. Мартин Фаулер“Архитектура корпоративных программных приложений” (PoEAA). За определениями Transaction Script / Table Module / Domain Model и за графиком “сложность vs усилия” – туда.
  5. Скотт Миллетт, Ник Тьюн“Предметно-ориентированное проектирование: паттерны, принципы и методы”. Хороший мост между стратегией и тактикой, с примерами на .NET.
Поделиться: Telegram X