Крупное приложение стареет незаметно. Сначала команда ставит заплатку в спешке перед релизом, потом вторую, потом уходит человек, который один понимал модуль расчётов. Через пять лет руководитель проекта честно говорит: дальше так нельзя, систему надо менять. И тут начинается развилка, на которой спотыкаются целые отделы разработки. Первый путь выглядит смело: заморозить разработку функций и за один большой релиз заменить всё сразу. Второй путь выглядит скромнее: переносить приложение на новую основу маленькими частями, не останавливая поставку ценности. Практика корпоративной разработки отвечает на вопрос о выборе настолько устойчиво, что спор потерял смысл: полная замена за один заход почти всегда проигрывает, а постепенная стратегия strangler fig регулярно доезжает до конца. Разберём её механику, инструменты и ошибки, которые ломают даже правильный план.

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

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

Первая причина: поведение старой системы известно лишь частично. Часть правил живёт в коде, часть в головах ветеранов, часть в данных, которые однажды обработали руками из-за инцидента и с тех пор обрабатывают именно так. Требование "сделай как в старой" невыполнимо в принципе, потому что никто не знает до конца, как именно в старой. Вторая причина: вместе с нужным придётся переписать ненужное. В типичном корпоративном приложении солидная доля функций не используется годами, но заранее неизвестно, какая именно доля, поэтому в смету честно закладывают всё подряд. Третья причина: бизнес не ждёт. Пока команда пишет замену, рынок требует новые функции. Их либо параллельно делают в старой системе, и объём переписи растёт каждый спринт, либо не делают, и компания теряет доход и терпение спонсора.

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

Откуда взялся паттерн strangler fig и как работает его механика

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

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

Иэн Картрайт, Роб Хорн и Джеймс Льюис позже описали четыре вида деятельности, из которых складывается такой подход:

  1. зафиксировать, какие именно результаты бизнес ждёт от модернизации, и превратить их в измеримые цели;
  2. раздробить проблему на небольшие независимые части, каждая из которых ценна сама по себе;
  3. поставлять эти части в эксплуатацию одну за другой с откатом на каждом шаге;
  4. перестроить процессы и структуру команды так, чтобы постепенная модернизация стала постоянным режимом работы.

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

Маршрутизатор запросов как опорная точка постепенного перехода

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

const routes = [
  { path: "/api/orders", target: ordersServiceV2 },
  { path: "/api/billing", target: legacyMonolith },
  { path: "/api/reports", target: legacyMonolith },
  { path: "/api/health", target: edgeChecker }
];

Когда новый модуль подрос, раскладку дополняют флагами и долями трафика:

{
  "orders_v2": {
    "enabled": true,
    "trafficShare": 15,
    "customerAllowlist": ["partner-42"]
  }
}

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

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

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

Как выбрать очередность переноса модулей и не застрять на полпути

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

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

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

Branch by abstraction когда общий шлюз развернуть нельзя

Внешнюю точку входа не всегда удаётся поставить: вход в систему закрыт вендорской платформой, протокол нельзя трогать или у команды просто нет права менять адреса. Тогда применяют приём branch by abstraction, описанный Полом Хаммантом, и весь переход происходит внутри монолита мелкими пул-реквестами.

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

public interface TariffCalculator {
    BigDecimal compute(OrderSnapshot order);
}

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

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

Метрики, откат и правила параллельной жизни двух систем

Параллельная работа старого и нового кода требует правил, иначе она становится постоянной. Расписание долей трафика фиксируют заранее: 1%, затем 5%, 25%, 50%, 100%, с контрольными точками на каждом уровне. Автоматика следит за метриками: доля ошибок, медиана и p95 задержки, расхождение ответов при теневом прогоне. Порог отката задают числом, а не ощущениями: рост ошибок выше 0.5% за десять минут возвращает флаг в ноль, и разбор причин происходит утром, без ночных звонков.

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

Прогресс измеряют маршрутами и кодом: "к концу второго квартала 60% запросов обрабатывает новая система, доля старых модулей не выше 40%". Такая формулировка даёт спонсору понятные точки контроля и защищает проект от иллюзии близкого завершения, в которой постепенные стратегии умирают чаще всего.

Типичные ошибки при внедрении strangler fig и как их обойти

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

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

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