← назад к разделу

Со временем большинство серьёзных проектов превращается в трудноизменяемые системы. DDD — это подход, который помогает этого избежать, привязывая структуру кода к бизнес-домену. Разберём с нуля: в чём проблема, как её решает DDD и когда это действительно нужно.

одно понятие бизнеса — и три разных имени в коде без общего языка перевод бизнес говорит «заявка» приём Request биллинг Order поддержка Ticket правка: «заявку можно отозвать» приёмRequestбиллингOrderнашли 2 из 3 правкуне внесли в поддержке заявка так и висит открытой с единым языком одно имя на всех бизнес говорит «заявка» приём Application биллинг Application поддержка Application та же правка: «заявку можно отозвать» приёмApplicationбиллингApplicationподдержкаApplicationвсе 3 сразу одно слово — видно все три места

Между разговором и кодом стоит перевод: одно и то же понятие становится Request, Order и Ticket. Правка «заявку можно отозвать» находится по двум знакомым словам из трёх — третий модуль называет это иначе и остаётся со старым поведением. Единый язык убирает разнобой: у понятия одно имя на все модули — пусть и латиницей, зато везде одно и то же, — и правка находится целиком.

Проблема: бизнес и код говорят на разных языках

Представьте типичный корпоративный проект через пару лет разработки:

  • Бизнес говорит «заявка», в коде — полный разброд. Request, Order, Ticket и Application в разных сервисах — и все про одно и то же.
  • Изменение бизнес-правила ломает всё. «Добавить скидку для VIP» превращается в правку 15 файлов, потому что логика размазана по обработчикам, сервисам и SQL-запросам.
  • Модули тянут друг друга. Платёжный модуль знает про склад, склад — про уведомления. Любое изменение вызывает каскад.
  • Новый человек тонет. Погружение занимает месяцы, потому что нет явной структуры и единого языка.

Domain-Driven Design (DDD) — это подход, в котором структура и язык кода привязаны к бизнес-домену: к тому, как бизнес описывает свои процессы. Не к фреймворку, не к базе данных, не к API. Термин ввёл Эрик Эванс в книге «Domain-Driven Design: Tackling Complexity in the Heart of Software» (2003).

Ubiquitous Language — единый язык команды

Первое, с чего начинается DDD: разработчики и бизнес-эксперты договариваются об общем словаре. Этот словарь называется Ubiquitous Language (единый язык).

Что это значит на практике:

  • за каждым словом из разговора с бизнесом стоит ровно одно имя в коде;
  • если бизнес говорит «заявка», класс называется Application, а не Request или Ticket;
  • если появляется новый бизнес-термин — он сразу появляется в коде.

Имена в коде так и остаются латиницей — буквального совпадения букв никто не требует. Требуется другое: одному слову бизнеса отвечает ровно одно имя в коде, и это имя одинаково во всех модулях.

Без единого языка возникает «перевод» между тем, что хочет бизнес, и тем, что написано в коде. Перевод — это потери: неточности, баги, потраченное время на выяснения.

Bounded Context — граница, где слова однозначны

Одно и то же слово в разных частях системы может означать разное. «Клиент» в отделе продаж — это потенциальный покупатель. «Клиент» в бухгалтерии — это плательщик с реквизитами. «Заказ» в момент оформления и «заказ» на складе — совершенно разные наборы данных.

Bounded Context (ограниченный контекст) — это явная граница, внутри которой термины имеют однозначный смысл. Каждый контекст живёт своей моделью, и это нормально: не нужно мучить одну универсальную модель «Заказа», пытаясь угодить всем.

У контекста один хозяин: одна команда, один набор терминов, один язык. Обратное неверно — одна команда спокойно ведёт два-три контекста, это обычное дело. А вот делить один контекст между двумя командами нельзя: язык тут же начнёт расходиться. На пересечениях контекстов — явные правила взаимодействия.

Контекст — это не микросервис

Самая дорогая ошибка чтения этой статьи делается прямо здесь, и лучше остановить её сразу: ограниченный контекст — это граница модели, а не единица развёртывания. Из «у нас четыре контекста» не следует «у нас четыре сервиса».

Как это выглядит в жизни:

  • Несколько контекстов в одном приложении — самый частый и совершенно нормальный случай. Границы проходят по пакетам и модулям: у каждого контекста свои классы, своё понимание слова «заказ», свои таблицы, и они не лезут друг в друга напрямую. Такой монолит с явными границами называют модульным, и он проще микросервисов, а границы даёт те же.
  • Один контекст в нескольких сервисах — бывает, когда сервис разделили по технической причине (отдельный обработчик тяжёлых задач, отдельное чтение), но модель осталась одна.
  • Контекст == сервис — частый и удобный случай, но следствие, а не определение: границы модели обычно оказываются хорошим местом для разреза, если решили разрезать.

Почему это важно для читателя, который только знакомится: границы можно провести сегодня, не меняя развёртывание. Пакеты, отдельные таблицы, запрет на импорт между контекстами, общение через явный интерфейс или событие — это работа на дни, и она даёт главную пользу DDD. Вынос в отдельный сервис — отдельное решение с отдельной ценой (сеть, выкат, наблюдаемость, распределённые отказы), которое принимают по другим причинам: разные требования к масштабированию, разные команды, разные сроки выката. Разбор этих причин — в статье про монолит и микросервисы.

Практический вывод: начинайте с границ внутри одного приложения. Если позже понадобится вынести контекст в сервис, границы уже проведены, и вынос станет механической работой вместо переписывания.

Aggregate — граница изменений

Внутри одного контекста данные не существуют в изоляции: объекты связаны между собой. Но если менять их как попало — нарушается целостность.

Aggregate — это кластер связанных объектов с одним корневым объектом (Aggregate Root). Все изменения идут только через корень. Это обеспечивает целостность: нельзя поменять строку заказа, не пройдя через сам заказ.

Пример: Order — корень агрегата, OrderLine — внутренний объект. Нельзя добавить строку, минуя метод Order.addLine(...). Агрегат сам следит за своими правилами.

Граница агрегата — это также граница транзакции: одна операция меняет один агрегат. Это рабочее правило, а не закон природы. Его иногда нарушают сознательно — например, перевод денег трогает сразу два счёта, — но платят за это усложнением и разбирают такие случаи отдельно.

Domain Event — что произошло в домене

Когда что-то важное происходит в системе, об этом нужно сообщить другим частям. Domain Event — это факт, который уже случился в домене.

Примеры: OrderConfirmed («заказ подтверждён»), PaymentReceived («платёж принят»), ItemShipped («товар отправлен»).

Ключевое слово — «произошло». Событие описывает прошлое, а не команду на будущее. Другие контексты могут реагировать на событие, но не диктуют, как оно обрабатывается внутри источника.

События делают контексты слабосвязанными: платёжный сервис не знает про доставку, он просто публикует PaymentReceived, а доставка подписывается и реагирует.

Чем платят за события

Событие выглядит бесплатной развязкой, и стоит сразу назвать цену — иначе картина мира получится неверной, а исправлять её придётся в проде.

Согласованность становится отложенной. Между «заказ оплачен» и «резерв на складе сделан» проходит время: миллисекунды в хорошем случае, минуты при отставании обработчика, часы при аварии. В этом окне система рассогласована: заказ оплачен, а склад об этом не знает. Значит, надо ответить на вопрос «что видит пользователь в этом окне» — и ответ должен быть предусмотрен, а не обнаружен.

Доставка не бесплатна. Событие может не дойти (упало между сохранением данных и отправкой) или прийти дважды (повтор после сбоя). Отсюда два обязательных механизма, которых не видно на схеме: надёжная публикация (событие сохраняется в той же транзакции, что данные, а отправляется отдельно — это таблица исходящих сообщений) и идемпотентность получателя (повтор не создаёт второй резерв). Их не добавляют потом: они меняют устройство обработчика.

Порядок не гарантирован по умолчанию. «Резерв сделан» может прийти раньше «заказ оплачен», если события идут разными путями. Лечится ключом, по которому события одного заказа попадают в один поток, — и это решение, а не настройка по умолчанию.

Отладка усложняется. В синхронном вызове стек показывает всю цепочку; в событийной схеме её нет. Вопрос «почему резерв не сделан» требует связывать журналы двух сервисов по общему идентификатору — то есть наблюдаемость становится обязательной, а не желательной.

Договор становится публичным. Как только событие читают другие, его формат — контракт: поле нельзя переименовать, смысл нельзя тихо изменить, а старую версию придётся поддерживать, пока есть читатели.

Ничего из этого не аргумент против событий: они действительно развязывают контексты, и часто это правильный выбор. Аргумент в том, что событие переносит сложность, а не убирает её — из синхронной цепочки в инфраструктуру и в обработчики. Механика всех перечисленных пунктов — в статье про паттерны интеграции.

Стратегические и тактические паттерны

DDD работает на двух уровнях:

Стратегический уровень — как разбить систему на части:

  • определить Bounded Context'ы и их границы;
  • нарисовать Context Map — кто с кем общается;
  • выделить Core Domain (ядро бизнеса, где максимальная сложность) и вспомогательные поддомены.

Тактический уровень — как писать код внутри одного контекста:

  • Aggregate и Aggregate Root;
  • Domain Event;
  • Value Object (объект-значение без идентичности: деньги, адрес, координаты);
  • Repository (абстракция над хранилищем агрегата);
  • Domain Service (логика, которая не принадлежит ни одному агрегату).

Частая ошибка — начинать сразу с тактических паттернов, не разобравшись со стратегическими. Сначала нужно понять границы и язык, потом — писать код.

Когда DDD оправдан, а когда нет

DDD — это инструмент для сложности. Там, где сложности нет, он добавит её сам.

DDD оправдан, когда:

  • бизнес-логика нетривиальна и часто меняется;
  • проект долгоживущий — годы, не месяцы;
  • несколько команд работают над одной системой;
  • домен сложный: маркетплейс, финансы, логистика, страхование;
  • разработчики и бизнес-эксперты могут регулярно общаться.

DDD избыточен, когда:

  • приложение — простой CRUD без существенной бизнес-логики;
  • маленькая команда, один контекст, понятные требования;
  • прототип, который нужно запустить быстро;
  • нет доступа к бизнес-экспертам.

Хорошее правило: если вся бизнес-логика помещается в один if в обработчике запроса — DDD не нужен. Если бизнес-правила занимают сотни строк и продолжают расти — стоит задуматься.

Во что обходится вход

Списки выше читаются как вкусовщина, пока не названа цена. Она есть, и она измеримая.

Время бизнес-экспертов — главная статья расхода. Единый язык не вырабатывается разработчиками между задачами: нужны люди, которые знают предметную область, и нужны регулярно. Порядок — несколько встреч по два-три часа на старте (событийный штурм — это полдня) и потом по часу в неделю на уточнения. Это не «поговорить разок»: именно доступность экспертов чаще всего и решает, получится ли DDD в проекте. Нет доступа — не начинайте, получится имитация.

Первый релиз выходит медленнее. Вместо «повторили структуру таблиц и поехали» команда сначала договаривается о понятиях, потом раскладывает их по паттернам. Порядок замедления на первом контексте — недели, а не дни. Окупается это на третьем-четвёртом изменении требований, когда правки перестают трогать всю систему; но окупается позже, чем хочется, и это надо сказать заранее.

Больше кода на то же поведение. Отдельные доменные классы и преобразование в записи базы означают два слоя вместо одного. Практически — на 20–40 % больше строк в тех местах, где границы проведены строго. Плата осознанная: этот код заменяет разбросанные по системе условия.

Обучение команды. Первые месяцы люди пишут «как раньше» и тянут привычки: сущности с публичными полями, вся логика в сервисе, репозиторий с двадцатью методами поиска. Значит, нужны ревью с объяснением и терпимость к переделкам; без этого через полгода получается прежний код с новыми именами классов.

Поддержка договорённостей. Единый язык, карта контекстов и записанные решения не поддерживают себя сами: кто-то обязан замечать расхождения и обновлять. Это несколько часов в месяц, которые в плане не появляются сами.

Итого честная формулировка: DDD — это вложение месяцев работы и постоянного времени экспертов ради того, чтобы через год изменения стоили дешевле. Проект короче года, домен без правил, недоступные эксперты — вложение не вернётся. Долгий проект со сложными и меняющимися правилами — вернётся с большим запасом.

Как это выглядит на живом проекте

«Взять DDD» на работающей системе не означает переписать её. Порядок, который работает:

1. Выбрать один контекст, и не самый большой. Кусок, у которого есть настоящие правила (не CRUD), понятная граница и активные изменения — то есть место, где вложение окупится быстро. Переписывать весь монолит не надо; более того, попытка — самая частая причина, по которой DDD в проекте «не пошёл».

2. Провести границу в существующем коде. Отдельный пакет или модуль, свои таблицы (или хотя бы явное правило, кто их владелец), запрет на импорт изнутри наружу. Остальной код продолжает работать как есть.

3. Поставить на границе переводчик. Старый код обращается к новому контексту через явный интерфейс, а не лезет в его классы; данные на входе и выходе преобразуются. Это защитный слой, и он позволяет новой модели быть честной, не подстраиваясь под старые названия полей. Разбор — в статье про паттерны интеграции.

4. Не трогать остальное намеренно. Остальная система остаётся в прежнем виде, и это нормально: два стиля в одном проекте — рабочее состояние, а не переходный позор. Правило простое: новый код в этом контексте пишется по новым правилам, старый переписывается только когда его и так пришлось менять.

5. Расширяться по мере надобности. Следующий контекст берут, когда в нём появилась сложность, а не по плану «перевести всё за год».

Чего не делать: объявлять «мы переходим на DDD» на всю систему, начинать с самого запутанного куска (там не хватит понимания даже для границ), и переписывать CRUD-части ради единообразия.

Как начать

Если решили попробовать:

  1. Поговорите с бизнесом. Event Storming — хороший формат для начала: собираете команду и выписываете все события в домене. Как он устроен по шагам, что нужно для проведения и что делать с результатом — в статье про событийный штурм.
  2. Зафиксируйте глоссарий. Список терминов — это ваш Ubiquitous Language. Пусть он живёт в документе и дополняется. Формат этой записи — понятия, связи, состояния и правила на одной странице — и то, как из неё получаются классы, разбирает статья про систему понятий.
  3. Определите контексты. Нарисуйте Context Map — кто с кем общается, кто от кого зависит.
  4. Начните с одного контекста. Выберите Core Domain и примените тактические паттерны. Не пытайтесь переписать всё сразу.

Коротко

  • DDD привязывает структуру кода к бизнес-домену, а не к фреймворку или базе данных.
  • Пока у понятия три имени в коде, правка доходит не до всех модулей, и ради этого заводят Ubiquitous Language: одному слову бизнеса отвечает одно имя. Слово однозначно только внутри своей границы, поэтому Bounded Context объявляют явно, вместо того чтобы искать одну модель заказа на всю систему.
  • Целостность держат не проверки в обработчиках, а корень Aggregate: изменения идут только через него, и одна операция меняет один агрегат. Тактические паттерны без проведённых границ и общего языка дают прежний код с новыми именами классов.
  • Domain Event сообщает о случившемся факте и не диктует получателю, что делать, поэтому источник не знает своих подписчиков и не меняется при их появлении.
  • DDD оправдан при сложной и меняющейся бизнес-логике; для простого CRUD он избыточен.
  • Ограниченный контекст — граница модели, а не единица развёртывания: несколько контекстов живут в одном приложении, границы проводятся пакетами и таблицами, а вынос в сервис — отдельное решение с другой ценой.
  • Событие переносит сложность, а не убирает: отложенная согласованность с окном рассогласования, надёжная публикация и идемпотентность получателя, ключ для порядка, обязательная наблюдаемость и публичный контракт формата.
  • Цена входа измеримая: несколько часов экспертов в неделю, первый контур медленнее на недели, на 20–40 % больше кода, месяцы привыкания команды и часы в месяц на поддержку договорённостей.
  • На живом проекте берут один контекст со настоящими правилами, проводят границу в существующем коде, ставят переводчик на границе и намеренно не трогают остальное.

Что почитать дальше