Как приручить legacy-код: безопасная модернизация без заморозки фич

Старую систему можно менять не останавливая продукт — если знать как. В статье разбираем, почему Big Bang-переписывание почти всегда заканчивается плохо, и как работает постепенная архитектурная эволюция: Strangler Fig Pattern, характеристические тесты, feature toggles и shadow testing на реальном трафике. С примером команды, которая потеряла десять человеко-месяцев — и потом сделала всё правильно.

Обложка: Как приручить legacy-код: безопасная модернизация без заморозки фич

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

Но при этом к такому коду часто боятся прикасаться. И не без причины. В старых системах редко бывает понятная карта зависимостей. Документация устарела, часть знаний живет только в головах нескольких разработчиков, а бизнес при этом продолжает ждать новых релизов, интеграций и продуктовых экспериментов.

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

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

Почему Big Bang-переписывание чаще всего заканчивается плохо

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

На старте такой план часто звучит логично. Особенно если текущая система действительно мешает развитию. Например, мобильное приложение растет, у него уже миллионы пользователей, бэкенд написан несколько лет назад как монолит, а каждая новая фича требует изменений в десятке мест. Команда устала чинить регрессии, бизнес устал ждать, и всем хочется «один раз нормально переписать».

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

Допустим, команда решила переписать модуль заказов в e-commerce-продукте. В старой версии есть корзина, промокоды, доставка и оплата. Команда планирует за полгода сделать новый сервис заказов. Но за эти полгода бизнес добавляет подписки, подарочные сертификаты, частичную оплату бонусами и новую логику возвратов. В итоге новая система, которую проектировали под старые требования, к моменту релиза уже нуждается в доработке.

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

Big Bang всё-таки бывает оправдан — но в узких условиях. Если кодовая база молодая (год-два), пользователей мало, у системы нет критичного состояния в БД и продукт можно временно заморозить или вести в обоих контурах параллельно, полное переписывание может оказаться дешевле постепенной миграции. Это редкая ситуация, и она быстро исчезает по мере роста продукта. В зрелых системах безопаснее работает другой подход — постепенная архитектурная эволюция.

Пример: как команда переписала сервис документов и потеряла полгода

Команда сопровождала сервис — старый модуль на aiohttp с Pydantic v1, через который проходила вся обработка путевых листов и актов осмотра транспорта. Сервис существовал шесть лет, был покрыт тестами фрагментарно, а его API использовали мобильное приложение водителей, диспетчерская веб-панель и пакетный импорт.

Команда решила переписать сервис целиком: перейти на FastAPI, обновить Pydantic до v2, заодно почистить контракты и заменить внутреннее хранилище документов с MongoDB на PostgreSQL. План был рассчитан на четыре месяца.

Через восемь месяцев проект всё ещё не был готов к выкатке, а к десятому месяцу команда откатила миграцию полностью. Причин было несколько.

Во-первых, переписывание шло параллельно с продуктовой разработкой. За время миграции бизнес добавил два новых типа документов и изменил правила подписи актов. Новая система проектировалась под старые требования и к моменту готовности уже не соответствовала продукту.

Во-вторых, команда не написала характеристических тестов. Поведение «как есть» нигде не было зафиксировано, и расхождения находились только в продакшене после переключения.

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

В-четвёртых, переключение было сделано «в лоб»: маршрут в API Gateway просто перенаправили на новый сервис. Фича-флага не было, теневого запуска не было, плана отката не было. Когда выяснилось, что новый сервис строже валидирует исторические форматы документов и отклоняет часть старых записей, быстро вернуться на старую реализацию не получилось — её к тому моменту уже отключили на стенде, а в БД успели уйти записи в новом формате.

В итоге миграцию свернули, потратив около десяти человеко-месяцев и потеряв доверие бизнеса. Сервис до сих пор работает в исходной реализации, а команда переходит к плану, описанному ниже.

Strangler Fig Pattern: как заменить систему по частям

Один из самых практичных подходов к модернизации legacy-кода — Strangler Fig Pattern. В софтверном виде паттерн был сформулирован Мартином Фаулером в 2004 году под названием StranglerFigApplication. Идея проста: не переписывать систему целиком, а постепенно выносить отдельные части в новую реализацию.

Название пришло из биологии. Фикус-душитель растет вокруг дерева-хозяина и постепенно вытесняет его. В архитектуре принцип похожий: старая система продолжает работать, новая функциональность появляется рядом, а затем отдельные потоки постепенно переводятся на новую реализацию.

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

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

Strangler Fig хорошо работает там, где между старым и новым кодом есть сетевая граница: HTTP, message bus, RPC. Если такой границы нет — например, нужно постепенно заменить функцию или класс внутри одного процесса — используется родственный паттерн Branch by Abstraction: над старой реализацией создается абстракция, рядом пишется новая реализация, переключение происходит через конфигурацию или фича-флаг, после стабилизации старая ветка удаляется. Снаружи это выглядит как Strangler Fig, но без сетевого прокси.

Такой подход снижает риск. В системе нет одного большого релиза, где всё меняется сразу. Есть серия небольших контролируемых изменений. Каждое можно протестировать, измерить и откатить.

Главное правило: сначала повторить поведение, потом улучшать

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

Это опасный путь. В legacy-системах странное поведение часто существует не случайно. За ним может стоять неочевидное бизнес-правило, старый клиент, интеграция с внешней системой или исторический баг, на который уже кто-то завязался.

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

Прежде чем менять поведение, его нужно зафиксировать. Для этого пишут характеристические тесты (characterization tests, иногда называемые golden master или approval tests). Идея простая: на реальных данных или их обезличенных копиях прогоняется старая реализация, её ответы сохраняются как эталон, и любые будущие изменения, отклоняющиеся от эталона, отлавливаются автоматически. Тесты пишутся не для красоты, а для того, чтобы зафиксировать существующее поведение — даже странное — перед тем, как его трогать. Подробно эта техника описана у Майкла Физерса в книге Working Effectively with Legacy Code; на практике её удобно реализовать через библиотеки семейства approval-tests (approvaltests-python, approvaltests-java и аналоги).

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

Feature toggles: как включать новую логику без риска

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

В обычной разработке релиз часто выглядит бинарно: код либо выкатили, либо нет. При миграции legacy это неудобно. Гораздо безопаснее иметь возможность включить новую реализацию для 1% пользователей, затем для 10%, потом для половины аудитории и только после этого для всех.

Например, команда переносит расчет стоимости доставки из монолита в новый сервис. С помощью фича-флага это выглядит так:

			def calculate_delivery_cost(order: Order, user_id: str) -> Money:
 	if feature_flags.is_enabled("new_delivery_calculation", user_id=user_id):
     	    return new_delivery_service.calculate(order)
 	return legacy_delivery.calculate(order)
		

user_id передается явно, чтобы решение «попал ли пользователь в новый сегмент» было стабильным от запроса к запросу. Иначе один и тот же клиент будет получать разные ответы при обновлении страницы, и поведение системы станет непредсказуемым.

На первом этапе флаг включают только для внутренней команды. Потом для тестового сегмента пользователей. Затем для небольшой доли реального трафика. Если метрики стабильны, долю увеличивают. Если появляются ошибки, флаг выключают, и пользователи снова идут в старую реализацию.

Важно различать два разных типа флагов. Флаг постепенной выкатки (rollout flag) меняется редко и контролирует, какой процент пользователей видит новую логику. Kill switch — отдельный флаг, единственная задача которого — мгновенно выключить новую реализацию при инциденте. Kill switch должен опрашиваться на каждом запросе, его кэширование должно жить секунды, а не минуты, и он принципиально не должен зависеть от той системы, которую он выключает. Иначе в момент аварии может оказаться, что выключатель сам недоступен.

В качестве инфраструктуры для флагов команды обычно берут одну из платформ: LaunchDarkly, Unleash, Flagsmith, GrowthBook, либо собирают собственную поверх Redis или конфигурационного сервиса. Для миграции важны три свойства: быстрое распространение изменений (секунды, а не минуты), поддержка таргетинга по пользователю/сегменту и аудит — кто и когда менял флаг.

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

Последний пункт особенно важен. Если флаги не удалять, система быстро превращается в набор ветвлений, где никто уже не понимает, какая логика актуальна.

Shadow testing: как проверить новую систему на реальном трафике

Feature toggles помогают безопасно переключать пользователей. Но перед этим хорошо бы понять, совпадает ли новая логика со старой. Для этого используют shadow testing.

Shadow testing — это запуск новой реализации параллельно старой, но без влияния на пользователя. Пользовательский запрос по-прежнему обрабатывает старая система, а новая получает копию запроса и считает результат «в тени». Пользователю этот результат не показывается. Команда только сравнивает ответы.

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

			async def calculate_discount(cart: Cart) -> Discount:
 	legacy_result = legacy_discount_service.calculate(cart)

 	# Новая реализация работает в фоне — ответ пользователю её не ждет.
     asyncio.create_task(_shadow_compare(cart, legacy_result))

 	return legacy_result


 async def _shadow_compare(cart: Cart, legacy_result: Discount) -> None:
 	try:
     	new_result = await new_discount_service.calculate(cart)
         diff_recorder.record(cart_id=cart.id, legacy=legacy_result, candidate=new_result)
 	except Exception:
         logger.exception("shadow comparison failed", extra={"cart_id": cart.id})
		

Два момента, на которые стоит обратить внимание в этом коде. Теневой вызов запускается через asyncio.create_task — корутина сразу планируется в event loop и начнёт выполняться, как только функция вернёт управление. И весь блок завернут в try/except: исключение в новой логике не должно ронять основной запрос. Без этих двух свойств shadow testing рискует ухудшить продакшен вместо того, чтобы безопасно его проверить.

Небольшая оговорка для продакшена: event loop держит на task только слабую ссылку, и без сохранённой ссылки задача может быть собрана сборщиком мусора прямо во время выполнения. В реальном коде Task имеет смысл класть в set фоновых задач и удалять оттуда через add_done_callback. В примере выше эта обвязка опущена для читаемости.

Для критичной доменной логики — платежей, биллинга, расчета тарифов — допустимый уровень расхождения должен быть около нуля: цель в shadow-режиме не «как можно меньше различий», а «понимаем каждое расхождение». Для менее чувствительных доменов (рекомендации, ранжирование результатов поиска) можно жить с расхождением в долях процента, но и там расхождения нужно классифицировать, а не игнорировать. Возможно, это баги новой реализации. А возможно, старая система содержит устаревшую логику, которую нужно отдельно обсудить с бизнесом.

Shadow testing особенно полезен для критичных доменных частей: платежей, биллинга, расчета тарифов, персональных предложений, транзакций. Там нельзя просто «попробовать на пользователях» и посмотреть, что будет.

При этом важно отличать теневую проверку чтения от теневой проверки записи. Чтение проверить относительно дёшево: запрос идёт в обе системы, ответы сравниваются, никаких внешних эффектов нет. С записью всё сложнее. Если новая реализация в shadow-режиме действительно создаст заказ, спишет деньги или отправит письмо, у пользователя возникнут двойные эффекты. Поэтому для writes либо вводят идемпотентные ключи и shadow-режим без реальных побочных действий (внешние вызовы заменены no-op-стабами, БД — отдельной shadow-копией), либо вообще отказываются от теневой проверки записи в пользу постепенной выкатки за фича-флагом.

Сравнение ответов в реальной системе тоже не сводится к одной функции compare. Нужно отдельно решать, как игнорировать «нормальный» шум (метки времени, идентификаторы, порядок коллекций), как сэмплировать трафик, чтобы не утопить хранилище расхождений, и как организовать триаж — кто и в каком ритме разбирает накопившиеся диффы. Готовые решения этого класса — GitHub Scientist (Ruby и его порты в другие языки), Twitter Diffy, либо собственный лёгкий регистратор поверх Kafka и таблицы расхождений.

С чего начинать модернизацию

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

Удобный способ выбрать первый кусок — оценить кандидатов по двум осям: насколько модуль критичен для бизнеса (low / high) и насколько сильно он связан с остальной системой (low / high). Начинать стоит с квадранта low-criticality + low-coupling: ошибки в нем не уронят бизнес-показатели, а малое количество зависимостей позволит провести миграцию полностью, не утянув за собой смежные модули. Высоко-критичные и сильно связанные части (платежи, ядро авторизации) трогают в последнюю очередь — на этот момент команда уже наберёт опыт безопасной миграции.

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

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

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

Миграция данных: самая сложная часть

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

Базовый рабочий приём — Expand-Contract (он же Parallel Change). Изменение схемы делается в три такта. На этапе expand в БД добавляются новые поля, таблицы или индексы, при этом старое поведение полностью сохраняется. Затем — migrate: обе реализации начинают писать и в старое, и в новое место (dual writes), а отдельный фоновый процесс делает backfill — заполняет новые поля историческими данными. После этого читатели по одному переключаются на новую схему. Только когда никто из читателей не использует старую структуру, наступает contract — удаление лишних колонок и таблиц.

Несколько практических деталей, которые часто упускают:

· Dual writes — это не бесплатная операция. Две записи означают две точки отказа. Если одна из них упала, нужно решать, что делать: продолжать ли работу, ставить ли событие в очередь на повтор, помечать ли запись как несогласованную. Простое «сначала пишем туда, потом сюда» в продакшене на нагрузке приводит к расхождениям.

· Backfill часто длиннее, чем кажется. На большой таблице миграция в одном UPDATE блокирует продакшен. Поэтому backfill делают батчами по N тысяч строк с паузами, отслеживают прогресс и предусматривают возможность остановить и продолжить.

· Онлайн-изменения схемы на крупных таблицах делаются не штатным ALTER TABLE, а специализированными инструментами: gh-ost или pt-online-schema-change для MySQL, встроенные онлайн-механизмы PostgreSQL для индексов и колонок, Liquibase/Flyway — для управления версионированием изменений в репозитории.

· Shadow testing данные не покрывает. Можно сравнить, что новая реализация возвращает то же, что и старая, но если за этим стоит другая схема в БД, проверка корректности самой миграции данных — это отдельная работа: сверки, контрольные суммы, выборочный аудит исторических записей.

Без этих шагов любая красивая фасадная архитектура наталкивается на разъезжающиеся данные — и тогда даже идеальный Strangler Fig снаружи не спасает.

Прокси-слой как точка контроля

Чтобы постепенно заменять legacy-код, нужно управлять маршрутизацией запросов. Для этого часто создают прокси-слой, API Gateway или фасад, через который проходит обращение к старой и новой логике. В терминах Domain-Driven Design такой слой часто называют Anti-Corruption Layer: он защищает новую реализацию от старых контрактов и наоборот, позволяя двум моделям сосуществовать без взаимного «загрязнения».

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

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

В качестве технической основы команды обычно берут один из трех вариантов: классический API gateway (Kong, AWS API Gateway), service mesh (Envoy, Istio) или более простой reverse proxy (NGINX, HAProxy). Service mesh особенно удобен, когда трафик уже идёт внутри Kubernetes-кластера: маршрутизацию можно менять конфигурацией, без правок кода клиентов и сервисов.

Например, мобильное приложение обращается к endpoint /orders/history. Раньше этот endpoint напрямую обслуживал монолит. После введения API Gateway приложение продолжает ходить по тому же контракту, но внутри gateway может решать, куда направить запрос: в legacy-модуль или новый сервис истории заказов.

Управление маршрутизацией обычно делается не «всё или ничего», а на основании атрибутов запроса: значения заголовка (X-Migration-Cohort: new), куки, хэша от user-id (стабильное разбиение пользователей на сегменты) или географического региона. Это позволяет выкатывать новую реализацию сначала на одну страну, на сотрудников самой компании или на тестовый сегмент — и только потом расширять охват.

Для клиента ничего не меняется. Для команды появляется управляемость.

Наблюдаемость: без метрик миграция превращается в гадание

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

Минимальный набор — это логи, метрики и распределенная трассировка (distributed tracing). Нужно понимать, сколько запросов идет в старую и новую реализацию, сколько ошибок возникает, как меняется latency, где появляются таймауты, какие статусы возвращаются, какие бизнес-метрики проседают.

Технические метрики стоит формулировать не как «средний ответ» и «процент ошибок», а в терминах SLI и SLO: целевые показатели вида «99.9% запросов на /orders/history отвечают быстрее 300 ms за 30 дней» с явным error budget. Latency измеряется по перцентилям (p50, p95, p99) — среднее значение почти всегда обманчиво, а хвосты распределения говорят о реальном опыте пользователя. На время миграции имеет смысл выставить отдельные SLO для нового и старого пути и сравнивать их.

В качестве инструментов де-факто стандартом стал OpenTelemetry для трассировок, метрик и логов — единый протокол, который пишет в практически любое хранилище. Дальше — Prometheus и Grafana для метрик, Jaeger или Tempo для traces, Sentry или аналог для ошибок. Для миграции важна возможность фильтровать метрики по «варианту» — отдельно по старому и новому пути — иначе все цифры смешаются и реальную динамику будет не видно.

Технических метрик недостаточно. Если команда переносит оформление заказа, важно смотреть не только на 500 ошибки и время ответа, но и на конверсию в оплату, количество брошенных корзин, повторы запросов, обращения в поддержку.

Пример: новая система формально отвечает быстрее старой и не дает ошибок. Но после включения на 10% пользователей падает конверсия в оплату. Причина может быть не в серверной ошибке, а в изменении порядка полей, другом тексте сообщения или потере какого-то edge-case. Без бизнес-метрик команда может решить, что миграция успешна, хотя для продукта она уже создает проблему.

Практическая последовательность миграции

Рабочая последовательность обычно выглядит так.

Сначала команда выбирает ограниченный участок системы. На этом этапе важно не просто назвать модуль, а описать его границы. Какие сценарии он закрывает? Кто его вызывает? Какие данные он читает и пишет? Какие внешние интеграции использует? Какие неочевидные бизнес-правила в нем есть?

Затем поверх legacy-логики создается стабильный контракт. Это может быть API, фасад, gateway или отдельный слой внутри приложения. Главная задача — сделать так, чтобы клиенты зависели не от внутренней реализации, а от понятного интерфейса. На этом этапе полезно вспомнить про contract testing (Pact, Spring Cloud Contract): автотесты со стороны потребителей фиксируют, что именно они ожидают от API, и предупреждают о ломающих изменениях до того, как они доедут до продакшена.

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

Следующий этап — shadow testing. Новая система получает копии реальных запросов, считает результат, но пользователю по-прежнему возвращается ответ legacy. Команда сравнивает результаты и устраняет расхождения.

Когда новая реализация достаточно стабильна, начинается постепенное переключение через feature toggles. Сначала внутренние пользователи, потом 1% реального трафика, затем 5–10%, затем 50% и только после этого 100%.

На каждом этапе команда смотрит на метрики. Если всё стабильно, движение продолжается. Если появляются проблемы, флаг выключается, трафик возвращается в legacy, а команда разбирает причины.

Последний этап — удаление старого кода. Это не формальность, а обязательная часть миграции. И «удалить старый код» — это не один коммит, а явный Definition of Done: вырезана старая ветка кода, удалён фича-флаг, обновлена документация и схемы архитектуры, переименованы или удалены устаревшие дашборды и алерты, обновлены runbook’и для on-call и проведено короткое внутреннее обучение. Если этого не сделать, через полгода никто уже не вспомнит, какой путь актуален, и легаси-ветвление останется в коде навсегда.

Откат миграций: дешёвый только пока не пошли записи

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

Поэтому ещё на этапе проектирования каждое изменение должно сопровождаться явным планом отката. Удобно различать три типа шагов.

Полностью обратимые шаги. Чтение через новый сервис, расчёт «в тени», новые метрики. Откат — выключить флаг. Это самый комфортный режим, и в нём стоит держать миграцию как можно дольше.

Обратимые с компенсацией. Новая реализация пишет дополнительные данные (например, дублирует операции в новую таблицу), но старый источник тоже обновляется. Откат возможен, но требует решить, что делать с уже записанными данными: оставить, очистить, синхронизировать. План этих действий должен быть написан до выкатки, не во время инцидента.

Forward-only. После некоторой точки откат становится невозможен — например, после того, как старая схема удалена или внешние интеграции перенастроены на новый сервис. Такие шаги допустимы, но к ним нужно приходить отдельно, осознанно, с особенно строгими SLO в предыдущем этапе. До forward-only-перехода имеет смысл подержать систему в режиме параллельной работы дольше, чем по графику.

Базовое правило: ни один шаг миграции не должен уходить в продакшен, если у команды нет письменного ответа на вопрос «как мы откатываемся в случае проблемы». Иначе при инциденте откатываться будут на ходу — и не факт, что успешно.

Пример: как тот же сервис мигрировали со второй попытки

После неудачного опыта команда взялась за тот же сервис заново, но изменила подход.

На первом шаге они зафиксировали поведение существующего сервиса. На самые часто используемые сценарии (создание путевого листа, подпись акта осмотра, выгрузка пакета документов за период) написали характеристические тесты на реальных продакшен-данных, обезличенных и сохранённых как фикстуры. Любое будущее изменение поведения теперь падало в CI как явное расхождение.

Параллельно команда провела инвентаризацию побочных эффектов. Из исходного кода и логов выяснилось, что сервис не только хранит документы, но и: публикует событие в Kafka при смене статуса, инкрементирует счётчик в Redis для рейтинга водителей, отправляет webhook во внешнюю систему партнёра, пишет в таблицу аудита. Каждый из этих эффектов попал в отдельный пункт чек-листа «что должно остаться» в новой реализации.

Затем команда выбрала первый кусок для выноса — не весь сервис, а только чтение документов (GET /documents/{id} и GET /documents/by-driver/{driver_id}). Это была наименее рискованная часть: ошибки в чтении неприятны, но не ломают финансовые потоки.

Новый сервис написали на FastAPI рядом со старым. На уровне API Gateway появилось правило маршрутизации: запросы на чтение шли в старый сервис, но в фоне дублировались в новый. Ответ пользователю всегда возвращал legacy, а ответ нового сервиса сравнивался с эталоном и записывался в отдельную таблицу для разбора. Использовали обёртку поверх asyncio.create_task — на ответ пользователя теневой вызов не влиял.

За три недели shadow-режима команда нашла четыре расхождения. Два оказались багами новой реализации (округление времени, неправильная сортировка вложений). Два — давно забытыми особенностями старого сервиса (одно поле возвращалось в UTC, другое — в локальной зоне; так было исторически, бизнес не возражал, но в новой реализации захотели единый формат). Все четыре зафиксировали явно: баги — починили, особенности — согласовали с продуктовой командой как осознанное изменение.

Когда расхождений не осталось, включили фича-флаг на сотрудников самой компании. Через неделю — на 1% реальных водителей. Дальше шаг по 5%, 25%, 50%, 100% с паузой в несколько дней между этапами. На каждом шаге следили не только за HTTP-ошибками и latency, но и за продуктовыми метриками: количество подписанных актов, время от открытия документа до подписи, доля повторных запросов. Один раз пришлось откатиться с 25% на 5% — в одном из регионов выросло время отклика из-за неэффективного запроса. Исправили, выкатили снова.

Через два месяца чтение полностью перешло в новый сервис. Старый код чтения и фича-флаг удалили в том же релизе. После этого по той же схеме мигрировали запись документов, потом публикацию событий, потом импорт из внешних систем. Полная миграция заняла девять месяцев — почти столько же, сколько провалившийся Big Bang, — но продукт всё это время продолжал развиваться, инцидентов не было, и в конце команда осталась с системой, которую понимает.

Типичные ошибки при работе с legacy

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

Вторая ошибка — недооценивать скрытые зависимости и побочные эффекты. Legacy-код часто делает больше, чем кажется. На один и тот же вызов могут быть навешаны: запись в таблицу аудита, инкремент счётчика в кэше, публикация события в очередь, обновление статуса связанной сущности, инвалидация кэша, дёрганье webhook’а во внешнюю систему. Если в новой реализации воспроизвести только явный путь, скрытые потребители молча перестанут получать данные — и узнают об этом через жалобу бизнеса, а не через ошибку в логах. Поэтому перед выносом любого модуля имеет смысл составить инвентаризацию побочных эффектов: пройтись по коду и логам и выписать каждое нелогичное действие отдельным пунктом чек-листа.

Третья ошибка — отсутствие наблюдаемости. Без логов, метрик и трассировки команда не управляет миграцией, а угадывает. Особенно опасно смотреть только на технические ошибки и игнорировать бизнес-показатели.

Четвертая ошибка — не договариваться с бизнесом. Модернизация не должна быть невидимой «инженерной активностью в стол». Её нужно встраивать в roadmap, объяснять эффект и договариваться о приоритетах. Если бизнес не понимает, зачем команда тратит время на миграцию, работа будет постоянно проигрывать новым фичам.

Пятая ошибка — не удалять старый код. Временное сосуществование старой и новой логики нормально. Вечное сосуществование — нет. Если legacy не удаляется, технический долг не уменьшается, а просто меняет форму.

Шестая ошибка — не удалять фича-флаги после миграции. Флаг, который сыграл свою роль и больше никогда не выключается, превращается в постоянное ветвление в коде. Через год команда не помнит, можно ли удалить такую ветку или там сидит важный edge-case. Через два — кода с такими «мёртвыми» флагами становится больше, чем основной логики. Поэтому каждый флаг должен заводиться с условием удаления («после полной выкатки и двух недель стабильной работы») и иметь ответственного, кто этим удалением займётся.

Отдельно стоит упомянуть организационную сторону. Закон Конвея работает и в обратную сторону: если новый и старый код владеются разными командами с разными приоритетами, миграция будет тормозиться независимо от выбранного паттерна. На время миграции имеет смысл явно проговорить, кто отвечает за переход, и не разделять старую и новую реализации между несовместимыми roadmap’ами.

Компромиссы, к которым нужно быть готовыми

Постепенная модернизация безопаснее Big Bang-переписывания, но она не бесплатна. Некоторое время система будет сложнее, чем раньше. В ней появятся старый и новый код, прокси-слой, фича-флаги, дублирование логики, дополнительные метрики.

Shadow testing увеличит нагрузку на инфраструктуру, потому что часть запросов будет обрабатываться дважды. Команде придется поддерживать дисциплину: документировать контракты, отслеживать флаги, удалять старую реализацию после миграции, поддерживать contract-тесты в актуальном состоянии.

Но это контролируемая сложность. Она распределена во времени и управляется инженерными практиками. В отличие от Big Bang-риска, где команда долго работает с минимальной обратной связью, а потом выкатывает один большой релиз с максимальной неопределенностью.

Когда Strangler Fig особенно оправдан

Постепенная миграция особенно хорошо подходит для систем, где downtime невозможен или слишком дорог. Это финтех, e-commerce, биллинг, мобильные бэкенды с большой аудиторией, высоконагруженные продукты, старые монолиты и системы с большим количеством интеграций.

Если продуктом ежедневно пользуются сотни тысяч или миллионы людей, нельзя позволить себе «переписать и посмотреть, что будет». Нужно менять архитектуру так, чтобы пользователь не замечал процесса миграции.

Этот подход также полезен там, где бизнес продолжает активно развивать продукт. Если фичи нельзя заморозить на полгода, модернизация должна идти параллельно с продуктовой разработкой.

Когда модернизацию лучше не делать

Постепенная миграция — мощный инструмент, но у неё тоже есть стоимость, и иногда правильный ответ — оставить систему как есть. Несколько сценариев, в которых модернизация плохо окупается.

Продукт, который уходит из эксплуатации. Если через год сервис будет выключен или заменён на покупное решение, тратить квартал на его рефакторинг бессмысленно. Достаточно стабилизировать то, что есть.

Модуль, который никто не трогает. Если код десятилетней давности продолжает работать, не падает, не требует изменений и не вызывает инцидентов, его «уродливость» — не повод его переписывать. Цель модернизации — упростить будущие изменения; если будущих изменений нет, цели тоже нет.

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

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

Что получает команда

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

Появляются понятные контракты, наблюдаемость, практика безопасных релизов, культура удаления старого кода. Разработчики перестают бояться legacy, потому что у них появляется метод, а не только желание «когда-нибудь всё переписать».

Для бизнеса это тоже выгодно. Продукт продолжает развиваться, сроки становятся более прогнозируемыми, риски крупных сбоев снижаются, а технический долг постепенно уменьшается.

Модернизация — это процесс, а не проект

Legacy нельзя «починить за квартал». Если система развивалась годами, она не станет простой после одного рефакторинга. Но её можно системно улучшать.

Strangler Fig Pattern, Branch by Abstraction, feature toggles, shadow testing и аккуратная миграция данных дают рабочую модель: выбрать ограниченный участок, описать контракт, реализовать новую версию, проверить её на реальном трафике, постепенно переключить пользователей и удалить старый код.

Это не самый быстрый путь. Зато он управляемый. А в зрелых продуктах управляемость важнее скорости.

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