Domain-Driven Design Стратегический DDD: домен, поддомены, единый язык
0%

Стратегический DDD: домен, поддомены, единый язык

Стратегический DDD: домен, поддомены, единый язык

Есть проекты, где каждая новая фича даётся всё тяжелее, хотя команда растёт. Код формально чистый: слои разделены, тесты зелёные, линтер молчит. Но разговор с бизнесом каждый раз начинается заново: бизнес говорит «нам нужно перевыставить полис», а в коде есть только DocumentService.process(docId, flag), и никто не может показать пальцем строчку, которая означает «перевыставление». Технический долг здесь не в качестве кода — он в том, что структура кода не соответствует структуре предметной области.

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

Статья — про три центральных понятия: домен и поддомены, дистилляцию ядра и единый язык (ubiquitous language). Ограниченные контексты вынесены в следующую статью, а мотивация трека и ответ на вопрос «нужен ли вам DDD вообще» — в обзоре.

Интуиция: DDD как картография, а не как архитектура

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

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

  1. Единственной «правильной» модели заказа не существует. Для склада заказ — строки с весом и габаритами, для бухгалтерии — документ с суммами и НДС, для поддержки — история коммуникаций. Один класс Order на всех даёт объект с 80 полями, половина которых null в любой момент.
  2. Модель устаревает вместе с бизнесом. Меняется способ зарабатывать деньги — обязана измениться модель, иначе код начнёт «сопротивляться» требованиям.
  3. Ценность модели — в решениях, которые она позволяет принять быстро. Модель красивая, но не отвечающая на вопросы бизнеса, — украшение.

Эванс формулирует это как «модель и реализация связаны узами» (model-driven design): если код не выражает модель, модель — фикция, живущая в презентации.

Словарь: домен, поддомен, модель, пространство задач и решений

Термин Что это Пример
Домен (Domain) Сфера деятельности, в которой работает организация; то, чем она зарабатывает Страхование грузоперевозок
Поддомен (Subdomain) Логически связная часть домена со своими экспертами и правилами Андеррайтинг, урегулирование убытков, биллинг
Модель предметной области Упрощение реальности, выбранное под конкретные задачи «Полис», «Риск», «Тариф» и их правила
Проблемное пространство Что нужно бизнесу (домен + поддомены) «Нам надо котировать полисы за 3 секунды»
Пространство решений Как мы это строим (контексты, сервисы, команды) Сервис QuoteEngine на Go + 1С для бухучёта

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

Поддомены в проблемном пространстве и ограниченные контексты в пространстве решений

На картинке специально показан «некрасивый», но нормальный случай: один legacy-контекст (1С) обслуживает сразу два поддомена, а два generic-поддомена отданы внешним SaaS. Стремиться к отображению 1:1 полезно, добиваться его любой ценой — нет.

Поддомены: как их находить

Поддомен — не «модуль» и не «микросервис». Практические признаки границы поддомена:

  • Свой эксперт. Если на вопросы про тарифы и про доставку отвечают разные люди — это два поддомена.
  • Своя терминология. Слово меняет значение или появляется термин, непонятный соседям.
  • Свой темп изменений. Промо-правила меняются еженедельно, правила бухучёта — раз в год.
  • Свои метрики успеха. Конверсия против точности начислений против SLA доставки.
  • Свой источник истины по данным.

Приёмы поиска: пройтись по оргструктуре и отчётности (отделы — не поддомены, но сильный сигнал: закон Конвея работает и в обратную сторону) и Event Storming, разобранный в отдельной статье — выкладываете события бизнеса на таймлайн и видите естественные «швы», где кластер событий кончается, а термины меняются.

Три типа поддоменов

Эванс различает core, supporting и generic поддомены. Разница не академическая — это прямые указания, куда девать деньги и людей.

Тип Определение Стратегия Кто пишет
Core (ядро) Даёт конкурентное преимущество; то, за что клиент выбирает вас Строить самим, максимально качественно, вкладывать сильных инженеров, применять весь тактический DDD Лучшая внутренняя команда
Supporting (поддерживающий) Нужен для работы бизнеса, но не отличает вас от конкурентов; специфичен для вашей компании Строить самим просто (CRUD, transaction script), не переусложнять Обычная команда / аутсорс
Generic (общий) Задача решена рынком: платежи, аутентификация, рассылки, бухучёт Купить/взять готовое, не изобретать Никто — берём SaaS/библиотеку

Две ошибки, встречающиеся в каждом втором проекте: писать generic-поддомен самим («свой биллинг», «свой поиск» — оправдывается словами «у нас специфика», а по факту специфики на 5% и она решается конфигурацией готового продукта) и относиться к core как к supporting (ядро отдают подрядчику, потому что «это же просто расчёт скидок», а через два года конкурентоспособность компании заперта в 4000-строчной хранимой процедуре, которую боятся трогать).

Классификация: два вопроса и квадрант

Рабочий алгоритм: задать про каждую область бизнеса два ортогональных вопроса.

  • Ось X — сложность модели: много ли здесь нетривиальных правил, инвариантов, исключений?
  • Ось Y — дифференциация: если мы сделаем это заметно лучше конкурентов, вырастет ли выручка?

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

Формализуем классификацию как процесс принятия решения:

Про антикоррупционный слой (ACL) на границе с generic-системами — в статье про карту контекстов.

Ядро мигрирует

Классификация поддомена не вечна. Поиск по товарам был core-поддоменом Amazon в начале 2000-х; сегодня приличный поиск — Elasticsearch за неделю настройки, то есть generic. Обратное движение тоже случается: доставка была supporting для маркетплейсов, пока её скорость не стала главным аргументом конкуренции — и логистика превратилась в core со своей командой и ML. Пересматривайте карту раз в 6–12 месяцев; признак того, что вы опоздали: бизнес просит «просто добавить фичу» в supporting-область, а реализация занимает квартал.

Domain Vision Statement

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

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

Единый язык (Ubiquitous Language)

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

Почему перевод дорог

Термин проходит цепочку переводов: эксперт → аналитик → разработчик → БД. Каждый переход — потеря информации, необратимая: из status = 2 уже не восстановить, что имел в виду андеррайтер.

Потеря смысла при цепочке переводов и её отсутствие при едином языке

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

Обратите внимание на последний шаг. Единый язык двунаправлен: не только код подстраивается под речь эксперта, но и эксперт уточняет формулировки, увидев их в виде явных правил. Часто именно попытка написать тест обнаруживает, что у бизнеса нет однозначного ответа.

Язык в коде: до и после

Классический «анемичный» код, где язык домена полностью потерян:

# ПЛОХО: язык домена отсутствует, правила размазаны по сервису
class Order:
    def __init__(self):
        self.status = 0          # 0 - новый, 1 - ?, 2 - подтверждён, 3 - ?
        self.items = []
        self.amount = 0.0        # какая именно сумма? с налогом? со скидкой?
        self.flag = False        # никто уже не помнит

class OrderService:
    def process(self, order_id: int, flag: bool) -> None:
        o = self.repo.get(order_id)
        if o.status == 0 and flag and o.amount > 0:
            o.status = 2
            o.flag = True
            self.repo.save(o)
        # А что если status == 1? Молча ничего не делаем — и это баг,
        # который невозможно обсудить с бизнесом: он не знает слов
        # "status" и "flag", а значит, не может проверить правило.

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

from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from enum import Enum

class PolicyState(Enum):
    """Состояния из речи андеррайтеров, а не из таблицы БД."""
    QUOTED = "quoted"        # котировка выпущена, риск не принят
    BOUND = "bound"          # риск принят страховщиком
    LAPSED = "lapsed"        # аннулирован из-за неоплаты в срок
    CANCELLED = "cancelled"  # расторгнут по инициативе сторон

@dataclass(frozen=True)
class Premium:
    """Объект-значение: премия — не просто число, у неё есть валюта."""
    amount: Decimal
    currency: str

    def __post_init__(self) -> None:
        if self.amount <= 0:
            raise ValueError("Премия должна быть положительной")

class PolicyCannotBeBound(Exception):
    """Доменное исключение: имя объясняет бизнес-запрет, а не сбой."""

class Policy:
    GRACE_PERIOD = timedelta(days=30)  # отсрочка оплаты для брокеров

    def __init__(self, number: str, premium: Premium) -> None:
        self.number = number
        self.premium = premium
        self.state = PolicyState.QUOTED
        self.bound_on: date | None = None
        self.paid_on: date | None = None

    def bind(self, today: date) -> None:
        """Связать полис — страховщик принимает риск. Оплата здесь ни при чём."""
        if self.state is not PolicyState.QUOTED:
            raise PolicyCannotBeBound(
                f"Связать можно только котировку, полис {self.number} в состоянии {self.state.value}"
            )
        self.state = PolicyState.BOUND
        self.bound_on = today

    def register_payment(self, on: date) -> None:
        """Оплата премии — отдельное событие в жизни полиса."""
        self.paid_on = on

    def is_overdue(self, today: date) -> bool:
        """Инвариант, сформулированный андеррайтером: 30 дней отсрочки."""
        if self.state is not PolicyState.BOUND or self.paid_on is not None:
            return False
        assert self.bound_on is not None
        return today > self.bound_on + self.GRACE_PERIOD

    def lapse(self, today: date) -> None:
        """Аннулировать за неоплату. Не то же самое, что расторжение."""
        if not self.is_overdue(today):
            raise PolicyCannotBeBound("Аннулировать за неоплату можно только просроченный полис")
        self.state = PolicyState.LAPSED

Что изменилось по существу, а не косметически:

  • Появилось явное различие между lapse и cancel — два бизнес-понятия, сливавшиеся в status = 3; различие обнаружено разговором, а не рефакторингом.
  • Невозможные состояния трудно выразить: связать уже связанный полис нельзя, метод бросит доменное исключение с понятным экспертам текстом.
  • Premium вместо amount — терминология эксперта плюс защита от смешения валют, а правило про 30 дней имеет имя (GRACE_PERIOD) и живёт рядом со своим понятием.

Тактическая механика (почему Premium — объект-значение, почему Policy — агрегат и где граница транзакции) — в статье про тактические блоки.

Жизненный цикл на языке домена

Когда состояния названы словами эксперта, диаграмма жизненного цикла становится документом, который бизнес может проверить и подписать:

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

Как удержать язык живым

Единый язык — не одноразовый глоссарий в Confluence, который через три месяца врёт. Приёмы, работающие на длинной дистанции.

1. Глоссарий рядом с кодом, в репозитории. Не в вики: то, что не лежит в PR, не проходит ревью и не обновляется.

# docs/glossary.yaml — единый язык контекста Underwriting
policy:
  ru: Полис
  definition: >
    Договор страхования. Существует с момента выпуска котировки;
    юридически действует с момента связывания (bind).
  code: domain.underwriting.Policy
  not_to_confuse_with: ["Заявка (в контексте Sales)", "Договор (в контексте Legal)"]

bind:
  ru: Связывание
  definition: "Момент, когда страховщик принимает риск. НЕ равно оплате премии."
  code: domain.underwriting.Policy.bind
  invariant: "Связать можно только полис в состоянии QUOTED"

lapse:
  ru: Аннулирование за неоплату
  definition: >
    Прекращение действия полиса из-за неоплаты премии в течение 30 дней после
    связывания. В отличие от расторжения — инициируется системой автоматически.
  code: domain.underwriting.Policy.lapse

2. Тесты как исполняемая спецификация. Тест на языке домена — единственная документация, которая ломается, когда врёт.

def test_полис_аннулируется_если_премия_не_оплачена_за_30_дней():
    policy = Policy("POL-001", Premium(Decimal("15000"), "RUB"))
    policy.bind(today=date(2026, 1, 10))
    # на 30-й день просрочки ещё нет — работает отсрочка для брокеров
    assert policy.is_overdue(date(2026, 2, 9)) is False
    # на 31-й день полис можно аннулировать
    assert policy.is_overdue(date(2026, 2, 10)) is True
    policy.lapse(today=date(2026, 2, 10))
    assert policy.state is PolicyState.LAPSED

Такой тест можно показать андеррайтеру и спросить: «здесь всё верно?». С assert order.status == 3 этот разговор невозможен. Дальше можно уйти в BDD — Cucumber или behave позволяют писать сценарии на русском, — но начинать стоит с имён в обычных тестах.

3. Автоматический контроль запрещённых слов. Дешёвый и действенный приём: CI падает, если в доменном слое появляется Manager, Helper, Data, Info или термин, которого нет в глоссарии.

# CI-проверка: в доменном слое не должно быть слов вне единого языка
grep -rnE 'class [A-Za-z]*(Manager|Helper|Processor|Data|Info)\b' src/domain/ \
  && { echo "Найдены имена вне единого языка домена"; exit 1; } || exit 0

4. Один язык — один контекст. Частое заблуждение: «единый язык» понимают как «единый на всю компанию». Это невозможно и вредно: «клиент» в маркетинге, поддержке и бухгалтерии — разные сущности с разными идентификаторами. Язык единый внутри ограниченного контекста, между контекстами — явный перевод. Об этом целиком следующая статья.

5. Переименование — не «косметика». Если бизнес говорит «котировка» вместо «заявки», переименование в коде — работа с бизнес-ценностью, и её нужно защищать в планировании наравне с фичами. Иначе язык расходится и всё построенное разваливается за пару кварталов.

Trade-offs: чего стоит стратегический DDD

Что даёт Чем платите
Код читается как бизнес-правила; онбординг сокращается с месяцев до недель Регулярное время экспертов: без 2–4 часов в неделю от реального эксперта ничего не работает
Границы систем и команд перестают быть случайными Часть решений (что мы НЕ строим) политически тяжела
Изменения бизнес-правил локализованы; цена фичи не растёт экспоненциально Переименования, миграции БД, ломающиеся API-контракты при выравнивании языка
Виден список того, что покупать, а не писать Модель нужно поддерживать: устаревшая модель хуже её отсутствия

Когда стратегический DDD не окупается: домен тривиален (CRUD-админка, лендинг — сложность модели ≈ 0); нет доступа к экспертам (DDD без эксперта — гадание с диаграммами); горизонт жизни системы месяцы, а не годы (прототип, MVP); домен целиком generic — берите готовую платформу.

Отдельно: применяйте DDD выборочно. Полный тактический DDD в core-поддомене плюс скучный CRUD в supporting — это не непоследовательность, а правильная стратегия; агрегаты и доменные события в справочнике городов — карго-культ, разобранный отдельно.

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

Процесс, а не разовый воркшоп. Карта поддоменов и карта контекстов живут в репозитории как текст (Markdown + mermaid), обновляются PR-ами и пересматриваются на квартальном планировании; ссылка на поддомен попадает в описание эпика.

Связь с оргструктурой. Границы, нарисованные в отрыве от команд, не выживают — прямое следствие закона Конвея. Team Topologies (Skelton, Pais) стыкуется с DDD напрямую: stream-aligned команда владеет одним core- или supporting-поддоменом целиком, platform-команда закрывает generic-часть. Если границу контекста делят три команды, она развалится.

Решения фиксируются как ADR. «Этот поддомен generic, мы покупаем» — решение с последствиями на годы; через два года его причин никто не вспомнит без записи. Формат ADR описан у Найгарда: https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.

Внедрение в legacy — отщепление, а не переписывание. Находим core-поддомен, погребённый в монолите; строим вокруг него контекст с ACL; переводим на язык домена; остальное трогаем по мере необходимости (Strangler Fig: https://martinfowler.com/bliki/StranglerFigApplication.html).

Типичные ошибки

  1. Начать с тактики. Команда читает про агрегаты и репозитории и строит идеальные агрегаты — но поперёк границ поддоменов. Результат: те же проблемы плюс лишние слои.
  2. Единый язык «для всей компании». Договориться об одном значении слова «клиент» на уровне организации не выйдет: либо бесконечные встречи, либо термин настолько размытый, что он бесполезен. Язык живёт в границах контекста.
  3. Глоссарий, оторванный от кода. Термины должны быть проверяемы: имя в глоссарии → имя класса или метода в коде.
  4. Считать core тем, что сложно. Сложность и ценность — разные оси; налоговый расчёт сложен и при этом почти всегда generic.
  5. Игнорировать миграцию ядра. Карта трёхлетней давности направляет инвестиции в commodity.
  6. Моделировать без эксперта. «Сами разберёмся по коду легаси» — так чужие баги воспроизводятся в качестве бизнес-правил.
  7. Считать, что DDD — это про структуру папок. Слои domain/, application/, infrastructure/ без работы над языком и границами дают ноль пользы — самый распространённый вид «DDD на бумаге». Сюда же: «некогда, переименуем потом» — язык расходится за квартал.

Мини-итог

  • Домен — то, чем занимается бизнес; поддомены — его связные части, которые вы обнаруживаете, а не придумываете.
  • Поддомены делятся на core (преимущество — строим сами и хорошо), supporting (специфично, но не отличает — строим просто) и generic (решено рынком — покупаем); классификация меняется со временем.
  • Проблемное пространство ≠ пространство решений: поддомены не обязаны отображаться на контексты один-к-одному.
  • Единый язык — словарь экспертов и разработчиков, буквально присутствующий в коде и тестах; единый внутри ограниченного контекста, а не во всей компании.
  • Цена DDD — время экспертов и дисциплина переименований. Если домен тривиален или экспертов нет, стратегический DDD не окупится.

Источники

Что дальше

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

Ограниченные контексты и карта контекстов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов