Domain-Driven Design Дистилляция ядра и эволюция модели
0%

Дистилляция ядра и эволюция модели

Дистилляция ядра и эволюция модели

Прошлая статья была про то, как углублять модель. Эта — про то, где это делать и что происходит с моделью потом.

Обе задачи звучат менее захватывающе, чем «спроектировать агрегат», и обе стоят дороже. Причина простая: в любой зрелой системе ядро домена — это 5–15 % кода, а остальные 85 % — обвязка: импорты, выгрузки, справочники, отчёты, интеграции, генерация PDF. Пока ядро физически перемешано с обвязкой, происходят три вещи, каждая из которых убивает пользу от DDD:

  1. Сильные инженеры тратят время не туда. Задача «поправить нумерацию документов» и задача «изменить правило принятия риска» выглядят в бэклоге одинаково и достаются кому попало.
  2. Новичка некуда отправить. На вопрос «где почитать, как устроен бизнес» честный ответ — «нигде, грепай по 41 классу».
  3. Ядро деградирует незаметно. Его никто не защищает, потому что никто не может показать пальцем, где оно начинается и заканчивается.

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


1. Лестница дистилляции

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

Ступень Приём Что делаете Стоимость Когда достаточно
1 Domain Vision Statement Полстраницы текста: что такое ядро и в чём его ценность Часы Команда до 10 человек, один контекст
2 Highlighted Core Путеводитель по ядру: список ключевых классов с объяснением, плюс пометки в коде Дни Кода много, но менять раскладку нельзя
3 Cohesive Mechanisms Вынести алгоритмическую машинерию за интерфейс, объявленный ядром Дни–недели В ядре живёт «как считать», а не «что считать»
4 Segregated Core Физически переместить ядро в отдельный модуль без исходящих зависимостей Недели Ядро понятно, но перемешано с обвязкой
5 Abstract Core Выделить самые общие понятия и их связи в отдельный слой, специализации — в модули Недели–месяцы Несколько похожих ветвей домена: авто, груз, здоровье
6 Generic Subdomains Вынести непрофильное наружу: купить, взять библиотеку, отдать другой команде Месяцы Обвязка растёт быстрее ядра

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


2. Highlighted Core: самый дешёвый приём, который почти никто не делает

Идея буквально в одном файле рядом с кодом.

<!-- underwriting/CORE.md — путеводитель по ядру контекста «Андеррайтинг» -->

## Что здесь ядро

Мы зарабатываем на том, что принимаем риски точнее конкурентов и отказываемся
от убыточных быстрее. Всё остальное существует, чтобы это работало.

| Понятие | Файл | За что отвечает | Кто эксперт |
|---|---|---|---|
| RiskAppetite | core/risk_appetite.py | Порог, за которым мы не берём риск ни за какие деньги | Гл. андеррайтер |
| PricingRule  | core/pricing.py      | Как факторы риска превращаются в премию | Актуарий |
| Coverage     | core/coverage.py     | Что именно покрыто и какие исключения | Юрист + андеррайтер |
| Policy       | core/policy.py       | Жизненный цикл договора и переходы состояний | Гл. андеррайтер |

## Чего здесь НЕТ и быть не должно

Нумерация документов, выгрузка в 1С, рассылка писем, генерация PDF, импорт CSV.
Всё это — supporting; менять его можно без андеррайтера.

## Правило ревью

PR, меняющий файлы из таблицы выше, требует ревью от владельца контекста
и упоминания правила в описании на языке эксперта.

Эффект несоразмерен затратам: появляется место, куда указывают на онбординге, появляется критерий для распределения задач и появляется формальный повод не пускать в ядро случайные изменения. Подкрепляется одной строкой в CI — например, CODEOWNERS на каталог ядра.


3. Cohesive Mechanism: вынести «как» из «что»

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

Ядро формулирует что нужно посчитать, в терминах домена. Механизм знает, как это посчитать, и не знает ни одного доменного термина.

# core/route_spec.py — ЯДРО. Здесь только язык домена.
@dataclass(frozen=True)
class RouteSpecification:
    """Требование клиента к перевозке — то, что обсуждают с экспертом."""
    origin: str
    destination: str
    deadline: date
    refrigerated: bool
    forbidden_ports: frozenset[str]

    def is_satisfied_by(self, itinerary: "Itinerary") -> bool:
        return (itinerary.arrival <= self.deadline
                and (not self.refrigerated or itinerary.all_legs_refrigerated)
                and not (itinerary.ports & self.forbidden_ports))


class RouteFinder(Protocol):
    """Порт: ядро объявляет ПОТРЕБНОСТЬ, а не способ её удовлетворить."""

    def cheapest(self, spec: RouteSpecification) -> "Itinerary | None": ...
# mechanisms/graph_route_finder.py — МЕХАНИЗМ. Тут нет слова «перевозка».
class DijkstraRouteFinder:
    """Поиск дешевейшего пути в графе расписаний. Про домен не знает ничего:
    для него это взвешенный ориентированный граф с временными окнами.

    Время: O(E log V) на запрос, память: O(V + E)."""

    def __init__(self, schedule_graph: "Graph") -> None:
        self._graph = schedule_graph

    def cheapest(self, spec: RouteSpecification) -> "Itinerary | None":
        candidates = self._graph.shortest_paths(
            src=spec.origin, dst=spec.destination, before=spec.deadline,
            edge_filter=lambda e: (not spec.refrigerated or e.refrigerated)
                                  and e.port not in spec.forbidden_ports)
        best = min(candidates, key=lambda p: p.cost, default=None)
        return Itinerary.from_path(best) if best else None

Что даёт разделение:

  • Ядро читается. RouteSpecification — 15 строк, понятных логисту. Дейкстра ушла туда, где ей место.
  • Механизм заменяем. Дейкстру можно поменять на A*, на внешний сервис, на кэш — ядро не заметит.
  • Тесты разделились. Правила домена тестируются перебором нескольких маршрутов в памяти; алгоритм — своими тестами на графах, включая свойства и производительность.
  • Компетенции разделились. Механизм пишет тот, кто силён в алгоритмах; ядро — тот, кто говорит с экспертом. Это разные люди, и раньше они дрались за один файл.

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


4. Segregated Core: физическое разделение

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

Изолированное ядро: до и после дистилляции модуля

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

  1. Договориться о списке. Берёте таблицу из CORE.md и фиксируете: вот эти 6 классов — ядро. Спор на этом шаге дешевле спора на пятом.
  2. Переместить, не меняя. Один коммит: только git mv и правка импортов. Ревью тривиально, откат тривиален.
  3. Развернуть стрелки. Каждая зависимость ядра на обвязку заменяется на интерфейс, объявленный в ядре, и реализацию снаружи — та же инверсия зависимостей, что в репозиториях.
  4. Зафиксировать границу в CI. Без автоматической проверки граница разрушится за два спринта.
  5. Переименовать по языку домена. Только теперь — когда видно, что осталось в ядре, имена становятся очевидными.
# setup.cfg — контракт границы: ядро не знает НИЧЕГО, кроме себя
[importlinter:contract:core-is-independent]
name = Ядро андеррайтинга не зависит ни от чего
type = forbidden
source_modules = underwriting.core
forbidden_modules =
    underwriting.supporting
    underwriting.generic
    underwriting.mechanisms
    underwriting.infrastructure
    sqlalchemy
    requests

Обратите внимание: mechanisms тоже в списке запрещённых. Ядро объявляет Protocol и не знает про реализацию — иначе третья ступень сделана только на словах. Проверить результат легко: тесты ядра должны запускаться командой без единого внешнего пакета и укладываться в секунды. Если для запуска нужен Postgres, вы ещё не там.


5. Abstract Core

Пятая ступень нужна редко, но там, где нужна, экономит годы. Ситуация: домен состоит из нескольких похожих ветвей, у которых общая структура и разные детали. Страхование: авто, груз, здоровье, имущество. Логистика: море, авто, авиа. Биллинг: подписки, разовые, потребление.

Соблазн — сделать один универсальный класс с флагом type. Это ровно тот антипаттерн, из-за которого появляется 80-полевой объект (см. ограниченные контексты). Правильный ход — выделить абстрактное ядро: самые общие понятия и связи между ними, без единой специализации.

Критерий, по которому понятие попадает в абстрактное ядро: оно нужно всем ветвям и ни одна ветвь не может его переопределить по-своему. Policy.bind() — момент принятия риска — одинаков для всех. RiskProfile.factors() — абстрактен по составу, но конкретен по роли.

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


6. Модель во времени: что происходит через два года

Дистилляция отвечает на вопрос «где ядро сегодня». Через год ответ меняется — и это нормальное поведение системы, а не признак ошибки.

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

  1. Появились «переводчики». Один-два человека, без которых нельзя понять, что означает поле. Знание вышло из кода обратно в головы.
  2. Прилагательные перед ключевыми существительными. «Внутренний заказ», «технический полис», «служебный клиент». Каждое прилагательное — это понятие, которому не хватило места в модели.
  3. Правила про правила. «Если тип 4, то поле X значит другое» — модель второго порядка, компенсирующая старое решение, а не выражающая домен.
  4. Растёт доля кода в обвязке, а изменения приходят в ядро. Значит, ядро сместилось, а раскладка — нет.
  5. Эксперт перестал спорить. Раньше на демонстрации моделей он поправлял формулировки, теперь молча кивает. Обычно это значит, что он перестал понимать, о чём речь.

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

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


7. Как удерживать модель живой

Три практики, каждая из которых дешевле, чем разовое «переписать через два года».

Дневник модели. Файл docs/model-log.md, куда одной строкой пишут каждое существенное изменение модели и его причину: «2026-03-14: разделили lapse и cancel — андеррайтер показал, что у них разные последствия для перестрахования». Через год это единственный документ, объясняющий, почему модель такая. По формату близко к ADR Найгарда, но короче и про домен, а не про технологию.

Регулярное моделирование, а не разовый воркшоп. Час в две недели с экспертом на разбор одного правила даёт больше, чем двухдневная сессия раз в год. Формат — из статьи про Event Storming, объём — одно правило.

Бюджет на языковой долг. Переименования, миграции и правку границ вносят в план как отдельную строку — 5–10 % ёмкости команды. Строка, которой нет в плане, не делается никогда, и это не вопрос дисциплины: любой продакт при выборе между фичей и переименованием честно выберет фичу.


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

  1. Начать с четвёртой ступени. Двигать файлы, не договорившись, что такое ядро. Итог — три недели работы и спор на ревью, который надо было провести в начале.
  2. Ядро по объёму кода. «Самый большой пакет и есть ядро» — обычно наоборот: самый большой пакет это обвязка, ядро в 10 раз меньше и в 10 раз ценнее.
  3. Механизм, притворяющийся доменом. Класс PricingEngine на 900 строк матриц — механизм с доменным именем. Признак: эксперт не может проверить ни одну строчку.
  4. Абстрактное ядро на двух ветвях. Абстракция, выведенная из двух примеров, почти всегда неверна; ждите третьего.
  5. Дистилляция без владельца. Изолированное ядро без CODEOWNERS и без ответственного зарастает обратно за квартал.
  6. Заморозка ядра. Обратная крайность: «ядро трогать нельзя». Ядро — самая живая часть системы, его надо менять чаще всего, просто осознанно и с экспертом.
  7. Инвестиции по инерции. Команда из десяти человек продолжает улучшать модель поддомена, который два года назад перестал быть ядром. Лечится календарной ревизией.

9. Мини-итог

  • Ядро — 5–15 % кода, дающие почти всю ценность. Пока оно перемешано с обвязкой, DDD не окупается.
  • Лестница дистилляции: vision statement → выделенное ядро → связные механизмы → изолированное ядро → абстрактное ядро → вынос generic наружу. Поднимайтесь ровно до исчезновения боли.
  • Самые дешёвые ступени — первые две, и они не требуют трогать код. Начинайте с них.
  • Связный механизм — код, который нельзя проверить с экспертом. Ему место за интерфейсом, объявленным в ядре.
  • Изолированное ядро проверяется одним фактом: его тесты идут секунды и не требуют ни одной внешней зависимости; границу держит CI, а не договорённость.
  • Модель живёт во времени: расходится с реальностью, окаменевает, мигрирует вслед за ядром бизнеса. Ревизия карты ядра — календарное мероприятие раз в полгода, а не реакция на аварию.

Источники

  • Eric Evans. Domain-Driven Design (2003), часть IV — главы 15 «Distillation» и 16 «Large-Scale Structure»; определения всех шести приёмов есть в бесплатном DDD Reference.
  • Vlad Khononov. Learning Domain-Driven Design (O’Reilly, 2021) — современный разбор миграции ядра и связи модели с бизнес-стратегией.
  • DDD Crew. Core Domain Charts и Bounded Context Canvas — готовые шаблоны для ревизии карты ядра.
  • Michael Nygard. Documenting Architecture Decisions — формат, из которого вырастает дневник модели.
  • Simon Wardley. Wardley Maps — про эволюцию компонентов от custom-built к commodity; лучший известный способ обосновать миграцию ядра цифрами.
  • Инструменты границ: import-linter (Python), ArchUnit (JVM), dependency-cruiser (TS).

Что дальше

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

Моделирование типами: недопустимые состояния невыразимы

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

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

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

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