DDD в legacy: как внедрить модель в живую систему
Все предыдущие статьи трека описывали, как должно быть. Реальность почти всегда другая:
есть система, ей семь лет, в ней 600 тысяч строк, три поколения разработчиков, база с 400 таблицами,
хранимые процедуры, ночные джобы и один человек, который помнит, зачем нужен флаг is_special.
Остановить её нельзя, переписать нельзя, а стоимость изменений растёт.
Эта статья — про то, что делать в понедельник. Не «как выглядит правильная архитектура», а последовательность шагов, каждый из которых можно закончить за спринт, оставив систему работающей.
1. Почему «перепишем заново» не работает
Разговор стоит начинать с этого, потому что предложение переписать поступит обязательно.
Аргумент первый — знание. В легаси-коде зашиты сотни решений, принятых по реальным причинам: обход бага у поставщика, требование регулятора 2019 года, костыль под клиента, который приносит 30 % выручки. Переписывая, вы выбрасываете это знание целиком и потом восстанавливаете его по инцидентам в проде — по одному за раз. Джоэл Спольски назвал это «худшей стратегической ошибкой» и был прав в главном: старый код не «плохой», он прочитанный жизнью.
Аргумент второй — арифметика. Пока идёт переписывание, бизнес не останавливается: старую систему
приходится развивать, и каждое изменение нужно вносить дважды. Если переписывание займёт T месяцев,
а поток изменений — r в месяц, то к моменту запуска вы должны догнать r · T изменений, которых
не было в исходном ТЗ. При типичных T = 18 это обычно означает, что новая система устаревает
до релиза. Второй эффект — «синдром второй системы» Брукса: команда, наконец получившая чистый лист,
кладёт в него всё, о чём мечтала.
Аргумент третий — риск. Big bang имеет ровно одну точку переключения, и провал в этой точке
невозможно откатить частично. Инкрементальный подход даёт n маленьких обратимых переключений.
Вывод, который стоит зафиксировать письменно (лучше как ADR): мы не переписываем систему, мы выращиваем рядом с ней новые контексты и переносим в них по одному сценарию.
2. Диагностика: где вообще болит
Первая ошибка энтузиаста — начать с того куска, который «уродливее всего». Уродство не равно стоимости. Начинать надо там, где пересекаются высокая частота изменений и высокая стоимость изменения, а такие места находятся не глазами, а по истории репозитория.
# 1) Churn: какие файлы правят чаще всего за последний год
git log --since="1 year ago" --name-only --pretty=format: \
| grep -E '\.(py|java|cs|ts|go)$' | sort | uniq -c | sort -rn | head -30
# 2) Логическая связность: какие файлы систематически меняются ВМЕСТЕ.
# Пары с высокой совместной частотой — либо один контекст, разрезанный
# по слоям, либо утечка модели через границу.
git log --since="1 year ago" --name-only --pretty=format:"@%H" \
| awk '/^@/{if(n>1&&n<12)for(i=1;i<=n;i++)for(j=i+1;j<=n;j++)print f[i]"|"f[j];n=0;next}
NF{f[++n]=$0}' \
| sort | uniq -c | sort -rn | head -30
# 3) Возраст и число авторов: файлы с 15+ авторами и ежедневными правками —
# почти всегда точка, где сталкиваются несколько контекстов
git log --since="1 year ago" --format='%an' --name-only \
| awk 'NF' | paste - - | sort -u | cut -f2 | sort | uniq -c | sort -rn | head -20
Дальше два вопроса к бизнесу, а не к коду: где мы теряем деньги из-за скорости изменений и что из этого является ядром (см. дистилляцию). Пересечение трёх множеств — часто меняется, дорого меняется, входит в ядро — и есть кандидат.
Отдельный, часто решающий критерий — есть ли у кандидата живой эксперт домена. Без эксперта получится не модель, а переписанный на новый лад легаси; это ровно тот стоп-фактор, о котором предупреждает обзор трека.
3. Швы: где физически можно резать
Шов (seam, термин Майкла Физерса) — место, где поведение можно подменить не меняя код вокруг. Швы бывают разного качества, и это определяет стоимость первого шага.
| Тип шва | Пример | Стоимость разреза | Риск |
|---|---|---|---|
| HTTP-эндпоинт | Nginx/шлюз маршрутизирует часть путей в новый сервис | Низкая | Низкий: откат — правка конфигурации |
| Очередь / топик | Новый потребитель подписывается на существующие сообщения | Низкая | Низкий: производитель не знает о вас |
| Публичный метод модуля | Фасад OrderFacade, за которым две реализации |
Средняя | Средний: нужен фича-флаг |
| Батч-джоба | Ночной расчёт запускается новым кодом, старый — в режиме сверки | Средняя | Низкий: результат сверяется до переключения |
| Таблица БД | Обе системы пишут в одну таблицу | Высокая | Высокий: нет транзакционной границы |
| Хранимая процедура | Логика в БД, вызывается отовсюду | Очень высокая | Очень высокий: вызовы не отслеживаются |
Правило: начинайте с самого дешёвого шва, даже если он не самый правильный. Первое выделение — это не архитектурное решение на десять лет, а проверка гипотезы о границе.
Характеризационные тесты: страховка перед первым касанием
Прежде чем что-либо трогать, нужно зафиксировать фактическое поведение — включая баги, потому что на баги наверняка кто-то полагается. Такие тесты Физерс называет характеризационными: они проверяют не «как должно быть», а «как есть сейчас».
# tests/characterization/test_discount_golden_master.py
# Цель — не корректность, а НЕИЗМЕННОСТЬ поведения при рефакторинге.
import json, itertools, pytest
from legacy.pricing import calculate_discount # старый код, как есть
def generate_cases():
"""Комбинаторный перебор входов: покрываем пространство, а не догадки."""
return itertools.product(
[0, 1, 999, 1000, 100_000], # сумма заказа, с граничными значениями
["new", "silver", "gold", None], # уровень клиента, включая NULL из БД
[0, 1, 5, 50], # число позиций
[True, False], # промо-период
)
@pytest.mark.parametrize("case", list(generate_cases()))
def test_поведение_расчёта_скидки_не_изменилось(case, golden: dict):
"""golden.json сгенерирован СТАРЫМ кодом и закоммичен в репозиторий.
Любое расхождение — сигнал, что рефакторинг изменил поведение."""
key = json.dumps(case)
assert calculate_discount(*case) == golden[key], f"поведение изменилось на {key}"
Практика показывает: генерация золотого файла на реальной выборке продовых входов (обезличенной) даёт покрытие, которого не добиться придумыванием кейсов. Заодно она обнаруживает первые сюрпризы — обычно два-три кейса, где старый код делает что-то, о чём никто не знал. Эти находки идут не в баг-репорт, а к эксперту домена: часто выясняется, что это и есть правило.
4. Первый ход — ACL, а не переписывание
Дальше возникает соблазн сразу написать красивую модель. Правильный первый шаг скромнее: обнести кусок легаси антикоррупционным слоем и начать говорить о нём на языке домена, не меняя ни строчки внутри.
# newcontext/infrastructure/legacy_pricing_acl.py
# Единственное место в новом контексте, знающее про формат легаси.
class DiscountPolicyPort(Protocol):
"""Порт объявлен НА ЯЗЫКЕ НОВОГО КОНТЕКСТА. Слова legacy здесь нет."""
def discount_for(self, order: Order, customer: Customer) -> Discount: ...
class LegacyDiscountAdapter:
"""Переводчик: чужая модель -> наша. Ни один тип легаси не проходит дальше."""
_TIER_MAP = {"new": Tier.BASIC, "silver": Tier.SILVER, "gold": Tier.GOLD, None: Tier.BASIC}
def discount_for(self, order: Order, customer: Customer) -> Discount:
raw = calculate_discount(
int(order.subtotal.amount * 100), # легаси хочет копейки int
{v: k for k, v in self._TIER_MAP.items()}[customer.tier],
len(order.lines),
self._promo_calendar.is_promo(order.placed_on),
)
if raw < 0: # легаси иногда возвращает -1
raise PricingUnavailable("расчёт скидки недоступен")
return Discount(Percent(Decimal(raw) / 100))
Что это даёт немедленно, ещё до всякой миграции:
- Новый код пишется на языке домена и не заражается терминологией легаси.
- Появляется точка подмены: реализацию можно заменить, не трогая потребителей.
- Странности легаси (
-1вместо ошибки,Noneвместо"new") собраны в одном файле и задокументированы. - Тесты нового контекста не требуют легаси: подставляется фейковый порт.
Подробно про сам паттерн — в статье про ограниченные контексты. Здесь важно другое: ACL — это не временная мера перед переписыванием, а самостоятельная ценность. Часть систем так и живёт годами: ядро в новом контексте, обвязка в легаси за слоем перевода.
5. Странгуляция: четыре стадии
Ключевая, самая недооценённая стадия — вторая: двойной прогон (dual run, shadow mode). Новый код считает то же самое параллельно со старым, но результат никуда не идёт — только в сравнение.
пока идёт сверка F->>N: рассчитать (асинхронно, вне критического пути) N-->>D: 1 240.00 L-->>D: 1 240.00 alt значения совпали D->>D: счётчик match++ else расхождение D->>D: записать входные данные, оба ответа, версии D-->>F: метрика mismatch_rate растёт, алерт при пороге end
Правила двойного прогона, которые стоит соблюдать буквально:
- Тень не пишет. Никаких побочных эффектов: ни записи в БД, ни писем, ни списаний.
- Тень не в критическом пути. Ошибка или таймаут тени не должны влиять на ответ клиенту.
- Расхождения сохраняются целиком — вход, оба выхода, версии кода. Без этого разбирать их невозможно.
- Порог переключения оговаривается заранее. Например:
mismatch_rate < 0.01 %на протяжении двух недель и полный разбор каждого класса расхождений. Иначе переключение превращается в спор. - Расхождение не значит, что новый код неправ. В половине случаев находится баг легаси. Решение принимает эксперт домена, а не разработчик.
Для случаев, когда шов внутри процесса, работает Branch by Abstraction (Fowler): вводится абстракция над старой реализацией, за ней постепенно появляется новая, переключение — флагом, старая ветка удаляется. Это позволяет вести работу в основной ветке месяцами без длинных feature-бранчей.
6. Данные — 70 % работы
Код переносится за недели, данные — за кварталы. Это главный источник срыва сроков в миграциях, и планировать надо от него.
новый контекст читает JOIN-ами"] --> B["Шаг 1: запретить JOIN через границу.
Новый контекст читает через API или проекцию"] B --> C["Шаг 2: новый контекст ведёт СВОЮ схему,
наполняет её из легаси через CDC или репликацию"] C --> D{"Кто пишет?"} D -- легаси --> E["Шаг 3a: синхронизация в одну сторону,
новый контекст read-only"] E --> F["Шаг 4: переключение записи.
Новый контекст — владелец"] D -- обе системы --> G["Опасная зона: двойная запись.
Только с ключом дедупликации
и разрешением конфликтов"] G --> F F --> H["Шаг 5: обратная синхронизация
для оставшихся читателей легаси"] H --> I["Шаг 6: читатели переведены,
старые таблицы УДАЛЕНЫ"] style G stroke:#cf5a4a,stroke-width:2px style I stroke:#2f9e6e,stroke-width:2px
Пояснения к самым дорогим шагам:
Шаг 1 (запретить JOIN через границу) обычно самый трудоёмкий. Пока два контекста джойнят таблицы друг друга, границы не существует, что бы ни было написано в документе. Замена — вызов API, локальная проекция или денормализованная копия нужных полей. Осознанное дублирование read-only данных здесь не ошибка, а плата за автономию.
Шаг 2 (наполнение своей схемы) делается через CDC, а не «перельём скриптом раз в сутки»: Debezium или логическая репликация PostgreSQL дают задержку в десятки миллисекунд и не нагружают источник запросами. Начальная загрузка — снимок, дальше поток изменений.
Двойная запись (красный блок) — состояние, в котором нельзя жить долго. Транзакционной границы между двумя системами нет, конфликты неизбежны. Если избежать её невозможно, нужны: ключ идемпотентности на каждой операции, явное правило разрешения конфликта («побеждает запись с большей версией») и ежедневная сверка с отчётом о расхождениях. Механика надёжной доставки — в статье про доменные события.
Шаг 6 — тот, который пропускают. Пока старые таблицы и старый код не удалены, миграция не закончена: вы платите за две системы, а любой инцидент требует разбираться в обеих. Удаление планируется как отдельная задача с датой, иначе оно не случается никогда.
7. План на кварталы и как его продать
Внедрение DDD в легаси — это не техническая инициатива, а инвестиция, которую нужно обосновать и о результатах которой нужно отчитываться. План на год выглядит примерно так:
Метрики, которые показывают прогресс и которые понимает бизнес:
| Метрика | Как считать | Что показывает |
|---|---|---|
| Доля сценариев в новом контексте | Число переведённых сценариев / всего | Прогресс, понятный без кода |
mismatch_rate |
Расхождения / прогонов в тени | Готовность к переключению |
| Lead time изменения правила | От тикета до прода, медиана | Ради чего всё затевалось |
| Доля инцидентов в мигрируемой области | Из системы инцидентов | Не сломали ли по дороге |
| Строк легаси удалено | git diff --stat по кварталу |
Единственная метрика завершения |
Последняя строка принципиальна. Миграция без удаления старого кода — это не миграция, а удвоение системы. Если через год удалено ноль строк, инициатива провалилась независимо от красоты новой модели.
Как продавать: не «мы хотим сделать архитектуру лучше», а «изменение тарифного правила сейчас занимает три недели и в трети случаев вызывает инцидент; после выделения контекста — два дня и отдельный релиз». Цифры берутся из диагностики первого раздела — поэтому её и делают до, а не после.
8. Люди: кто это делает
Антипаттерн — «команда миграции». Отдельная команда, которая переписывает то, что развивают другие, обречена: она всегда отстаёт, у неё нет владельца домена, а результат некому принимать. Работает обратное: команда, которая владеет областью, мигрирует её сама, а платформенная команда даёт инструменты (CDC, фасад, фича-флаги, наблюдаемость). Это прямое применение Team Topologies.
Границы контекстов должны совпадать с границами команд. Контекст, который делят три команды, развалится обратно; закон Конвея работает и здесь. Если организационно разделить нельзя — значит, момент для выделения контекста ещё не наступил.
Эксперт домена нужен постоянно, а не на старте. Два–четыре часа в неделю на протяжении всей миграции; из них половина — разбор расхождений двойного прогона. Именно там обнаруживаются настоящие правила бизнеса, которые до этого жили только в коде.
Расширение по одному человеку. Первый контекст делает маленькая группа из 2–4 человек. Массовое обучение команды DDD до появления первого работающего примера — деньги на ветер: люди применят паттерны по-своему, и вы получите десяток несовместимых интерпретаций.
9. Типичные ошибки
- Начать с самого уродливого куска. Уродство коррелирует не со стоимостью, а с возрастом. Начинать надо с дорогого и часто меняющегося.
- Резать по таблицам БД. Даёт распределённый монолит; разбор — в каталоге ошибок.
- Пропустить характеризационные тесты. «Мы и так понимаем, как оно работает» — до первого расхождения, которое окажется правилом, а не багом.
- Переключиться без двойного прогона. Экономия двух недель, которая оплачивается инцидентом в биллинге и потерей доверия к инициативе.
- Жить в двойной записи месяцами. Состояние без транзакционной границы — источник расхождений, которые потом чинят вручную по бэкапам.
- Не удалять старое. Две системы вместо одной, двойная стоимость поддержки, и никто не может сказать, где истина.
- Микросервис сразу. Сначала модуль в том же процессе с проверяемой границей, потом отдельная схема, и только потом — отдельный сервис (порядок из статьи про контексты).
- Модель без эксперта. В легаси это особенно опасно: вы аккуратно перенесёте в новый код чужие баги, приняв их за бизнес-правила.
- Миграция без даты окончания. Инициатива, у которой нет критерия завершения, превращается в фон, который сначала перестают финансировать, а потом отменяют.
10. Мини-итог
- Не переписывайте. Big bang проигрывает по знанию, по арифметике догоняющих изменений и по риску. Растите новый контекст рядом и переносите по одному сценарию.
- Кандидата выбирают по данным: churn и совместные изменения из git плюс ответ бизнеса на вопрос «где мы теряем деньги из-за скорости», плюс наличие живого эксперта.
- Перед первым касанием — характеризационные тесты: фиксируем поведение как есть, включая баги.
- Первый ход — ACL, а не новая модель: язык домена появляется сразу, легаси остаётся нетронутым.
- Странгуляция идёт четырьмя стадиями: фасад → двойной прогон в тени → переключение владения → удаление старого. Стадия двойного прогона — самая ценная и чаще всего пропускаемая.
- Данные — 70 % работы. Порядок: запретить JOIN через границу → своя схема через CDC → переключение записи → перевод читателей → удаление таблиц.
- Метрика завершения одна: удалённые строки легаси. Пока их ноль, миграции не было.
- Мигрирует та команда, которая владеет областью. Отдельная «команда миграции» — антипаттерн.
Источники
- Michael Feathers. Working Effectively with Legacy Code (2004) — швы, характеризационные тесты, техника разрыва зависимостей. Обязательное чтение для этой темы.
- Martin Fowler. StranglerFigApplication, BranchByAbstraction, ParallelChange — три базовых приёма безопасного изменения работающих систем.
- Sam Newman. Monolith to Microservices — самая практичная книга по разделению данных; главы 4–5 целиком про то, что здесь сжато в раздел 6.
- Joel Spolsky. Things You Should Never Do, Part I — классическая аргументация против переписывания.
- Adam Tornhill. Your Code as a Crime Scene и CodeScene — методика анализа истории репозитория, из которой взяты метрики churn и совместных изменений.
- Debezium. Документация по CDC — инструмент для шага наполнения своей схемы.
- Vlad Khononov. Learning Domain-Driven Design, гл. 11 «Evolving Design Decisions» — про то, как менять решения по мере роста понимания.
Что дальше
На этом трек по Domain-Driven Design закончен. Путь пройден целиком: от вопроса «зачем вообще» через стратегию, границы, тактику, события и код — к углублению модели, дистилляции ядра, типам, времени и внедрению в живую систему. Этого достаточно, чтобы вести внедрение самостоятельно и вовремя останавливаться там, где оно не нужно.
Куда двигаться дальше, в зависимости от того, что сейчас болит:
- Архитектура систем в целом — DDD задаёт границы, но не отвечает, как их разворачивать и эксплуатировать: архитектурные паттерны (в частности CQRS и event sourcing и модульный монолит), а заодно принципы проектирования и паттерны проектирования.
- Реализация на конкретном стеке — Go, C#, TypeScript или Elixir: у каждого языка своя цена на те же конструкции.
- Данные и надёжность — базы данных и распределённые системы: границы контекстов упираются в транзакции, репликацию и доставку сообщений.
- Работа с людьми и требованиями — системный анализ и инженерное лидерство: большинство провалов внедрения начинается задолго до первой строки кода.
Общая карта всех треков портала и рекомендуемый порядок чтения — в дорожной карте.