Дистилляция ядра и эволюция модели
Прошлая статья была про то, как углублять модель. Эта — про то, где это делать и что происходит с моделью потом.
Обе задачи звучат менее захватывающе, чем «спроектировать агрегат», и обе стоят дороже. Причина простая: в любой зрелой системе ядро домена — это 5–15 % кода, а остальные 85 % — обвязка: импорты, выгрузки, справочники, отчёты, интеграции, генерация PDF. Пока ядро физически перемешано с обвязкой, происходят три вещи, каждая из которых убивает пользу от DDD:
- Сильные инженеры тратят время не туда. Задача «поправить нумерацию документов» и задача «изменить правило принятия риска» выглядят в бэклоге одинаково и достаются кому попало.
- Новичка некуда отправить. На вопрос «где почитать, как устроен бизнес» честный ответ — «нигде, грепай по 41 классу».
- Ядро деградирует незаметно. Его никто не защищает, потому что никто не может показать пальцем, где оно начинается и заканчивается.
Дистилляция — набор приёмов Эванса, отвечающих ровно на это. А дальше — вторая половина статьи: модель не проектируют один раз, и то, что происходит с ней на горизонте лет, важнее исходного дизайна.
1. Лестница дистилляции
Шесть приёмов, упорядоченных по возрастанию стоимости. Правило простое: поднимайтесь по лестнице, пока боль не исчезнет, и ни ступенькой выше.
| Ступень | Приём | Что делаете | Стоимость | Когда достаточно |
|---|---|---|---|---|
| 1 | Domain Vision Statement | Полстраницы текста: что такое ядро и в чём его ценность | Часы | Команда до 10 человек, один контекст |
| 2 | Highlighted Core | Путеводитель по ядру: список ключевых классов с объяснением, плюс пометки в коде | Дни | Кода много, но менять раскладку нельзя |
| 3 | Cohesive Mechanisms | Вынести алгоритмическую машинерию за интерфейс, объявленный ядром | Дни–недели | В ядре живёт «как считать», а не «что считать» |
| 4 | Segregated Core | Физически переместить ядро в отдельный модуль без исходящих зависимостей | Недели | Ядро понятно, но перемешано с обвязкой |
| 5 | Abstract Core | Выделить самые общие понятия и их связи в отдельный слой, специализации — в модули | Недели–месяцы | Несколько похожих ветвей домена: авто, груз, здоровье |
| 6 | Generic Subdomains | Вынести непрофильное наружу: купить, взять библиотеку, отдать другой команде | Месяцы | Обвязка растёт быстрее ядра |
Ключевое, что часто понимают неправильно: ступени 1 и 2 не требуют трогать код вообще, и именно поэтому с них надо начинать. Команда, которая сразу лезет на четвёртую ступень, обычно обнаруживает, что не может договориться, какие классы относятся к ядру, — потому что не сделала первую.
на вопрос «что здесь ядро»?"} B -- нет --> V["1. Domain Vision Statement
полстраницы, согласовать с бизнесом"] B -- да --> C{"Новичок находит ядро
в коде за 10 минут?"} C -- нет --> H["2. Highlighted Core
путеводитель + пометки"] C -- да --> D{"В ядре есть алгоритмы,
не выражающие правил домена?"} D -- да --> M["3. Cohesive Mechanism
интерфейс в ядре, реализация снаружи"] D -- нет --> E{"Ядро зависит от обвязки
по импортам?"} E -- да --> S["4. Segregated Core
переместить и разорвать связи"] E -- нет --> F{"Есть 3+ похожие ветви
с общей структурой?"} F -- да --> AB["5. Abstract Core"] F -- нет --> G{"Обвязка растёт
быстрее ядра?"} G -- да --> GS["6. Вынести generic наружу"] G -- нет --> OK["Достаточно. Вернуться через квартал"] V --> OK H --> OK M --> OK S --> OK AB --> OK GS --> OK
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: физическое разделение
Четвёртая ступень — единственная, которая требует настоящего рефакторинга. Смысл: ядро становится модулем, у которого нет исходящих зависимостей на остальную часть контекста.
Порядок работ, проверенный на практике и устойчивый к тому, что посреди него придётся выпускать релиз:
- Договориться о списке. Берёте таблицу из
CORE.mdи фиксируете: вот эти 6 классов — ядро. Спор на этом шаге дешевле спора на пятом. - Переместить, не меняя. Один коммит: только
git mvи правка импортов. Ревью тривиально, откат тривиален. - Развернуть стрелки. Каждая зависимость ядра на обвязку заменяется на интерфейс, объявленный в ядре, и реализацию снаружи — та же инверсия зависимостей, что в репозиториях.
- Зафиксировать границу в CI. Без автоматической проверки граница разрушится за два спринта.
- Переименовать по языку домена. Только теперь — когда видно, что осталось в ядре, имена становятся очевидными.
# 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. Модель во времени: что происходит через два года
Дистилляция отвечает на вопрос «где ядро сегодня». Через год ответ меняется — и это нормальное поведение системы, а не признак ошибки.
Пять сигналов, что модель разошлась с реальностью, — по убыванию надёжности:
- Появились «переводчики». Один-два человека, без которых нельзя понять, что означает поле. Знание вышло из кода обратно в головы.
- Прилагательные перед ключевыми существительными. «Внутренний заказ», «технический полис», «служебный клиент». Каждое прилагательное — это понятие, которому не хватило места в модели.
- Правила про правила. «Если тип 4, то поле X значит другое» — модель второго порядка, компенсирующая старое решение, а не выражающая домен.
- Растёт доля кода в обвязке, а изменения приходят в ядро. Значит, ядро сместилось, а раскладка — нет.
- Эксперт перестал спорить. Раньше на демонстрации моделей он поправлял формулировки, теперь молча кивает. Обычно это значит, что он перестал понимать, о чём речь.
Отдельно про миграцию ядра. Классификация поддоменов из стратегической статьи не вечна — ценность мигрирует, и вслед за ней должны мигрировать инвестиции в моделирование.
Практический вывод: ревизия карты ядра — календарное мероприятие, а не реакция на боль. Раз в полгода: пересмотреть классификацию поддоменов, проверить, куда уходит время команды, и сверить это с тем, где по мнению бизнеса лежит конкурентное преимущество. Расхождение между «куда уходит время» и «где ценность» — самая полезная метрика архитектора и единственная, которую понимает финансовый директор.
7. Как удерживать модель живой
Три практики, каждая из которых дешевле, чем разовое «переписать через два года».
Дневник модели. Файл docs/model-log.md, куда одной строкой пишут каждое существенное изменение
модели и его причину: «2026-03-14: разделили lapse и cancel — андеррайтер показал, что у них разные
последствия для перестрахования». Через год это единственный документ, объясняющий, почему модель
такая. По формату близко к ADR Найгарда,
но короче и про домен, а не про технологию.
Регулярное моделирование, а не разовый воркшоп. Час в две недели с экспертом на разбор одного правила даёт больше, чем двухдневная сессия раз в год. Формат — из статьи про Event Storming, объём — одно правило.
Бюджет на языковой долг. Переименования, миграции и правку границ вносят в план как отдельную строку — 5–10 % ёмкости команды. Строка, которой нет в плане, не делается никогда, и это не вопрос дисциплины: любой продакт при выборе между фичей и переименованием честно выберет фичу.
8. Типичные ошибки
- Начать с четвёртой ступени. Двигать файлы, не договорившись, что такое ядро. Итог — три недели работы и спор на ревью, который надо было провести в начале.
- Ядро по объёму кода. «Самый большой пакет и есть ядро» — обычно наоборот: самый большой пакет это обвязка, ядро в 10 раз меньше и в 10 раз ценнее.
- Механизм, притворяющийся доменом. Класс
PricingEngineна 900 строк матриц — механизм с доменным именем. Признак: эксперт не может проверить ни одну строчку. - Абстрактное ядро на двух ветвях. Абстракция, выведенная из двух примеров, почти всегда неверна; ждите третьего.
- Дистилляция без владельца. Изолированное ядро без
CODEOWNERSи без ответственного зарастает обратно за квартал. - Заморозка ядра. Обратная крайность: «ядро трогать нельзя». Ядро — самая живая часть системы, его надо менять чаще всего, просто осознанно и с экспертом.
- Инвестиции по инерции. Команда из десяти человек продолжает улучшать модель поддомена, который два года назад перестал быть ядром. Лечится календарной ревизией.
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).
Что дальше
Мы разобрались, где углублять модель и как её удерживать. Дальше — про инструмент, который переводит договорённости о модели в проверяемые компилятором гарантии. Многое из того, что мы охраняли ревью, дисциплиной и тестами, можно сделать физически невыразимым: заказ без адреса, отменённая подписка с активным списанием, деньги в двух валютах в одной сумме.