Domain-Driven Design DDD в legacy: как внедрить модель в живую систему
0%

DDD в legacy: как внедрить модель в живую систему

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). Новый код считает то же самое параллельно со старым, но результат никуда не идёт — только в сравнение.

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

  1. Тень не пишет. Никаких побочных эффектов: ни записи в БД, ни писем, ни списаний.
  2. Тень не в критическом пути. Ошибка или таймаут тени не должны влиять на ответ клиенту.
  3. Расхождения сохраняются целиком — вход, оба выхода, версии кода. Без этого разбирать их невозможно.
  4. Порог переключения оговаривается заранее. Например: mismatch_rate < 0.01 % на протяжении двух недель и полный разбор каждого класса расхождений. Иначе переключение превращается в спор.
  5. Расхождение не значит, что новый код неправ. В половине случаев находится баг легаси. Решение принимает эксперт домена, а не разработчик.

Для случаев, когда шов внутри процесса, работает Branch by Abstraction (Fowler): вводится абстракция над старой реализацией, за ней постепенно появляется новая, переключение — флагом, старая ветка удаляется. Это позволяет вести работу в основной ветке месяцами без длинных feature-бранчей.


6. Данные — 70 % работы

Код переносится за недели, данные — за кварталы. Это главный источник срыва сроков в миграциях, и планировать надо от него.

Пояснения к самым дорогим шагам:

Шаг 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. Типичные ошибки

  1. Начать с самого уродливого куска. Уродство коррелирует не со стоимостью, а с возрастом. Начинать надо с дорогого и часто меняющегося.
  2. Резать по таблицам БД. Даёт распределённый монолит; разбор — в каталоге ошибок.
  3. Пропустить характеризационные тесты. «Мы и так понимаем, как оно работает» — до первого расхождения, которое окажется правилом, а не багом.
  4. Переключиться без двойного прогона. Экономия двух недель, которая оплачивается инцидентом в биллинге и потерей доверия к инициативе.
  5. Жить в двойной записи месяцами. Состояние без транзакционной границы — источник расхождений, которые потом чинят вручную по бэкапам.
  6. Не удалять старое. Две системы вместо одной, двойная стоимость поддержки, и никто не может сказать, где истина.
  7. Микросервис сразу. Сначала модуль в том же процессе с проверяемой границей, потом отдельная схема, и только потом — отдельный сервис (порядок из статьи про контексты).
  8. Модель без эксперта. В легаси это особенно опасно: вы аккуратно перенесёте в новый код чужие баги, приняв их за бизнес-правила.
  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 закончен. Путь пройден целиком: от вопроса «зачем вообще» через стратегию, границы, тактику, события и код — к углублению модели, дистилляции ядра, типам, времени и внедрению в живую систему. Этого достаточно, чтобы вести внедрение самостоятельно и вовремя останавливаться там, где оно не нужно.

Куда двигаться дальше, в зависимости от того, что сейчас болит:

Общая карта всех треков портала и рекомендуемый порядок чтения — в дорожной карте.

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

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

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

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