Как разрезать монолит пошагово

Монолит не переписывают за выходные — его душат: медленно, по кусочку, не выключая свет.

Strangler Fig (паттерн «удушающего фикуса») — способ миграции, при котором новая система постепенно обрастает вокруг старой и перехватывает её функции по одной, пока от старой не остаётся пустая оболочка, которую можно спокойно удалить.

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

Почему «перепишем с нуля» почти всегда заканчивается плохо

Соблазн понятен: старый код неприятен, новая архитектура в голове красива, кажется, что за полгода команда сделает то же самое, но правильно. На практике переписывание «большим взрывом» (big bang rewrite) упирается в три вещи.

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

В старом коде спрятано знание. Тот странный if с датой 2019 года — не мусор, а обход бага у платёжного провайдера, который до сих пор не починили. Такие вещи не задокументированы нигде: они живут только в коде и в памяти одного человека, который уже уволился.

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

КритерийПереписывание с нуляStrangler Fig
Когда бизнес увидит первую пользучерез 6–18 месяцев (или никогда)через 2–6 недель, на первом же вынесенном куске
Откаттолько «назад на полгода»переключение маршрута обратно за секунды
Рискодин огромный в концемного маленьких, каждый проверяем
Судьба легасиживёт параллельно, поддерживается двумя командамиусыхает и удаляется по кускам
Что происходит при остановке проектавсё выброшеновынесенные сервисы остаются и приносят пользу

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

Три обязательных элемента паттерна

  • Фасад-перехватчик. Точка, через которую проходит весь входящий трафик: реверс-прокси, API Gateway, иногда просто слой роутинга внутри самого монолита. Клиенты (мобильное приложение, фронтенд) не знают, что за фасадом что-то меняется — и это ключ ко всему.
  • Новый сервис. Полноценная реализация одной бизнес-возможности: со своим деплоем, своими метриками, желательно со своим хранилищем.
  • Правило маршрутизации. Настройка вида «этот путь — в новый сервис, остальное — в монолит». Она обязана меняться без деплоя: конфиг, фичефлаг, запись в базе. Если для переключения нужно собрать релиз — у вас нет рубильника, а без рубильника нет и права на эксперимент.
# Правила на фасаде. Читается сверху вниз, первое совпадение выигрывает.

POST /api/notifications/*   -> notification-service   100%   # вынесено полностью
GET  /api/search            -> search-service         10%    # канарейка
GET  /api/search            -> monolith               90%
ANY  /api/*                 -> monolith                      # всё остальное — по-старому

Обратите внимание: фасад — это не «ещё один сервис, который мы напишем потом». Он появляется первым, ещё до первого вынесенного сервиса, и поначалу тупо проксирует 100% трафика в монолит. Так вы заранее убеждаетесь, что фасад ничего не ломает, и получаете место, куда потом добавлять правила.

Что выносить первым

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

Хороший первый кандидат скучный. Его выбирают по признакам:

  • мало связей с остальным кодом — считайте общие таблицы, это самая честная метрика связанности;
  • у него понятная граница по бизнес-возможности («отправка уведомлений», а не «слой отправки писем»);
  • у него своя нагрузка, отличная от остальных, — есть что выиграть от независимого масштабирования;
  • цена ошибки терпимая: письмо ушло с задержкой — неприятно; деньги списались дважды — катастрофа;
  • результат можно измерить и показать бизнесу.
КандидатПочему удачный первый шагПодводный камень
Уведомления (email, push, SMS)почти нет чтения чужих данных: на вход приходит событие с готовым текстомшаблоны писем тянут за собой половину доменной модели, если не отрезать их сразу
Отчёты и выгрузкитолько чтение, можно работать с репликой базы; тяжёлые запросы уезжают с основной БДотчёты любят JOIN-ить всё со всем — придётся заранее решить, что читать
Поискотдельное хранилище (индекс), своя нагрузка, легко сравнить с монолитом по выдаченужен механизм наполнения индекса — обычно события или CDC
Загрузка и обработка файловизолированная задача, ест CPU и диск — приятно отселитьссылки на файлы разбросаны по всему монолиту
Платежи, корзина, заказПлохие первые кандидаты. Ядро домена выносят, когда конвейер уже отлажен на скучном.

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

Общая база на переходный период

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

Этап 0. Общая база, но разделённое владение

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

# Карта владения (заводится в первый же день, живёт в репозитории)

notification-service  ВЛАДЕЕТ: notifications, notification_templates, delivery_log
monolith              ВЛАДЕЕТ: users, orders, order_items, payments, ...

Правило: писать в чужую таблицу нельзя. Читать — только на этапе 0 и только по списку:
  notification-service ЧИТАЕТ users.email  (временно, до этапа 1)

Этот этап неприятен ревьюерам («мы же договорились не лезть в общую базу!»), но он единственный, который можно сделать за спринт и без риска. Важно лишь одно: временный доступ должен быть записан в явном списке, иначе он станет вечным.

Этап 1. Своя база и синхронизация

Сервис заводит собственное хранилище и копит в нём нужные ему данные. Копию наполняют событиями: монолит при изменении пользователя публикует событие UserEmailChanged, сервис обновляет свою локальную копию.

Здесь подстерегает ловушка двойной записи: код монолита сначала пишет в базу, потом шлёт событие в брокер. Между этими двумя действиями процесс может упасть — база обновлена, событие потеряно, копии разъехались навсегда. Лечится это паттерном Transactional Outbox: событие пишется в таблицу outbox в той же транзакции, что и изменение данных, а отдельный процесс вычитывает эту таблицу и отправляет в брокер. Либо тем же добиваются через CDC (Change Data Capture) — чтением журнала транзакций базы.

Этап 2. Разрыв

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

ЭтапГде данныеНезависимый деплой?Главный риск
0. Общая базаодна БД, разделённое владениечастично: миграция схемы всё ещё общая«временный» доступ к чужим таблицам становится постоянным
1. Своя база + синхронизациядве БД, копия наполняется событиямида, но данные догоняют с лагомдвойная запись → расхождение копий (лечится outbox / CDC)
2. Разрывдве БД, связь только через API и событияда, полностьюзабыли удалить старый код и старые таблицы

Отдельно стоит упомянуть anti-corruption layer — тонкий слой-переводчик на входе нового сервиса. Он превращает кривые структуры монолита («поле status = 7, что означает „оплачен частично“») в чистую модель нового сервиса. Без него легаси-модель просачивается в новый код, и через год вы обнаружите, что построили тот же монолит, только по сети.

Как это работает: переключение на живом трафике

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

  1. Теневой трафик (dark launch). Фасад зеркалирует запросы в новый сервис, но ответ пользователю по-прежнему отдаёт монолит. Ответ сервиса выбрасывается — или, что гораздо полезнее, сравнивается с ответом монолита, а расхождения пишутся в лог.
  2. Канарейка. Переключаем 1% трафика, смотрим сутки. Потом 5%, 25%, 50%, 100%. Между шагами обязательно проходит хотя бы один суточный цикл нагрузки: ночной джоб или утренний пик легко ломает сервис, который днём вёл себя идеально.
  3. Четыре метрики на каждом шаге: доля ошибок (5xx), задержка p95 и p99, число расхождений с монолитом, нагрузка на базу. Ухудшилось что-то одно — откат.
  4. Рубильник. Возврат на монолит — изменение конфига, а не релиз. Проверьте, что он работает, до того как он понадобится в три часа ночи.
  5. Удаление старого кода. Скучный, но обязательный шаг. Пока старая реализация жива, любой баг чинят в двух местах, а «временный» флаг живёт вечно.
# Зеркалирование: пользователю отвечает монолит, копия запроса уходит в новый сервис

location /api/notifications {
    proxy_pass  http://monolith;   # ответ идёт отсюда
    mirror      /shadow;           # копия запроса — в тень
}

location /shadow {
    internal;
    proxy_pass  http://notification-service;   # ответ отбрасывается, но пишется в лог
}

Расхождения из теневого прогона — золото. Именно там всплывает, что монолит округлял сумму «в пользу клиента», а новый сервис — математически, и на 40 тысячах заказов это уже деньги.

Частые ошибки

  • Начали с ядра домена. Заказ или платёж выносят последними: они связаны со всем и стоят дороже всего при ошибке.
  • Переносят код «как есть». Копипаст модуля вместе с его моделью данных даёт микросервис, который всё так же зависит от чужих таблиц. Перенос без переосмысления границ — это переезд, а не декомпозиция.
  • Ветка миграции живёт три месяца. Любой шаг фикуса должен вливаться в основную ветку минимум раз в неделю, пусть и за фичефлагом. Долгие ветки не мержатся — они умирают.
  • Старый код не удалён. Проект считается завершённым не тогда, когда новый сервис работает, а тогда, когда старый модуль и его таблицы стёрты, а флаг выпилен.
  • Двойная запись без outbox. «Сохранили в базу и отправили в брокер» — это два действия, между ними процесс падает. Рано или поздно копии разъедутся, и никто не поймёт почему.
  • Выносят пять кусков одновременно. Ни один не доведён до этапа 2, зато система теперь наполовину распределённая и целиком нестабильная. Один вынос за раз — до конца.
  • Нет критерия успеха. Если заранее не записано «время выкатки уведомлений сократится с 40 минут до 5», через полгода никто не сможет ответить, зачем всё это было.

Итоги

  • Big bang rewrite проигрывает по всем осям: длинный срок до первой пользы, отсутствие отката, гонка за движущейся мишенью.
  • Strangler Fig = фасад-перехватчик + новый сервис + правило маршрутизации, переключаемое без деплоя.
  • Первый кандидат на вынос — скучный, слабо связанный, с терпимой ценой ошибки: уведомления, отчёты, поиск. Ядро домена — в последнюю очередь.
  • Первый сервис дорогой, потому что вы оплачиваете конвейер (CI/CD, логи, метрики, gateway), а не сам сервис.
  • База режется в три этапа: общая БД с разделённым владением → своя БД + синхронизация через outbox/CDC → полный разрыв и связь только по API.
  • Переключение трафика: тень → канарейка → 100%, с рубильником на каждом шаге и обязательным удалением старого кода в конце.
Проверьте себя
1. Что означает паттерн Strangler Fig применительно к монолиту?
AЗаморозить разработку фич и за полгода переписать систему с нуля на новой архитектуре
BПостепенно перехватывать функции монолита новыми сервисами через фасад-маршрутизатор, пока от монолита не останется пустая оболочка
CРазбить монолит на сервисы механически: каждый класс становится отдельным микросервисом
DСкопировать монолит в несколько экземпляров и балансировать нагрузку между ними
2. Команда выносит из монолита первый сервис. Какой кандидат лучший?
AОформление заказа — это ядро системы, с него и надо начинать
BПроведение платежей — самая критичная часть, ей нужна надёжность в первую очередь
CОтправка уведомлений — слабо связана с остальным кодом, ошибка не фатальна, результат легко измерить
DАутентификация — её вызывают все, значит выигрыш будет максимальным