{ "index": 144, "slug": "editorial-2024-01-practice-legacy-modernization", "title": "Модернизация legacy-системы: как выбрать безопасный шов", "excerpt": "Полное переписывание редко начинается с доказанной границы. Разбираем, как выделить одну операцию, описать её поведение, подключить новый путь через адаптер и не спутать возврат маршрута с восстановлением данных.", "contentHtml": "
Симптом обычно появляется ещё до изменения кода: в backlog стоит задача «переписать расчёт заказа», но не названы один вход и один результат. Старый модуль одновременно принимает HTTP-запрос, применяет скидку, пишет статус, отправляет письмо и вызывает соседнюю систему. Команда создаёт новый сервис, а через несколько недель уже не может сказать, какая часть поведения перенесена. Цена ошибки — двойная запись, потерянное уведомление или откат, который меняет маршрут, но не возвращает данные.
\nБезопасный первый шаг — не новая технология, а измеримый шов. Шов — одна операция с названным потребителем, входом, результатом, эффектом и владельцем решения. Такая граница не обещает сохранить весь монолит. Она ограничивает изменение так, чтобы расхождение можно было увидеть, остановить и разобрать.
\nПапка, класс или отдельный микросервис не становятся границей автоматически. Граница возникает там, где можно сформулировать проверяемый контракт. Для HTTP это может быть один endpoint, для очереди — один тип сообщения и ключ повторной обработки, для интерфейса — одно действие пользователя.
\nПеред проектированием запишите пять полей. Consumer показывает, кто вызывает операцию. Input фиксирует обязательные поля, форматы и правила повтора. Response описывает статус и значимые значения. Effect перечисляет запись, публикацию, отправку письма, очистку кеша или отсутствие изменения. Owner принимает решение при расхождении. Если эффект пока неизвестен, так и напишите: unknown — это открытый риск, а не разрешение считать операцию безопасной.
| Поле | Пример | Проверка |
|---|---|---|
| Операция | POST /quote | Понятно, какой запрос входит в волну |
| Consumer | Сервис корзины | Назван конкретный вызывающий код |
| Response | total, expiresAt, код отказа | Выделены поля, влияющие на потребителя |
| Effect | Только чтение | Нет записи, публикации или внешнего вызова |
| Owner | Владелец расчёта | Есть человек или команда для решения stop/continue |
| Return | legacy-only | Следующий запрос можно вернуть на известный путь |
Для первой волны чаще подходит чтение или предварительный расчёт без изменения состояния. Операции preview и confirm нельзя объединять только потому, что они используют одну модель данных: у подтверждения появляются идемпотентность, порядок записи и компенсация внешних эффектов.
Совместимость — это не «новый endpoint вернул похожий JSON». Разделите её на четыре слоя. Transport — метод, путь, статус и значимые заголовки. Payload — типы, обязательность, null и отсутствие поля. Effect — запись, публикация, изменение кеша и поведение при повторе. Time — момент чтения состояния и смысл слова «повтор»: тот же набор полей, business key или idempotency key.
OpenAPI помогает описать HTTP-операции, параметры, схемы и ответы. Это полезная граница формы: другой инженер видит, как вызвать сервис и какие данные ожидать. Но схема сама по себе не говорит, отправил ли обработчик событие, когда он прочитал баланс и создаст ли второй запрос новую запись. Поведенческие свойства нужно фиксировать отдельными кейсами и наблюдаемыми эффектами.
\nНиже — учебный контракт для read-only расчёта. Он не описывает конкретную production-систему и не доказывает parity. В настоящем проекте названия полей, округление, коды ошибок и срок действия должны быть взяты из действующего обработчика, тестов или зафиксированного ответа legacy.
\ntype QuoteInput = {\n productId: string\n quantity: number\n customerTier?: 'base' | 'plus'\n}\n\ntype QuoteResult =\n | { status: 'ok'; total: number; expiresAt: string }\n | { status: 'rejected'; code: 'INVALID_QUANTITY' | 'NOT_AVAILABLE' }\n\nfunction routeQuote(input: QuoteInput, mode: 'legacy' | 'candidate') {\n return mode === 'candidate'\n ? modernQuoteAdapter(input)\n : legacyQuote(input)\n}\nАдаптер преобразует вход нового кода в старую внешнюю форму или наоборот, но не должен незаметно добавлять письмо, запись заказа или вызов confirm. Если для расчёта нужен скрытый глобальный флаг, общий кеш или внешний провайдер, это часть карты зависимости. Её нельзя спрятать за словом «gateway».
Оставьте legacy контрольным путём, а новый код подключайте только для выбранной операции. В каждом тесте должны быть как минимум три класса случаев: корректный ввод, отказ и повтор. Укажите предварительное состояние, запрос, ожидаемые поля, ожидаемый эффект и источник каждого ожидания. Для эффекта одного тела ответа недостаточно: два маршрута могут вернуть одинаковый JSON, но только один отправить событие.
\nКоманда ниже воспроизводит сравнение на тестовом контуре, если ваш маршрутизатор документированно поддерживает заголовок X-Route. Это условный интерфейс учебного адаптера, его нельзя отправлять в произвольный production endpoint. Значения BASE_URL и ITEM_ID задайте сами, а поля для нормализации согласуйте с владельцем контракта.
set -eu\n\ntest -n $BASE_URL || { echo 'set BASE_URL' >&2; exit 2; }\ntest -n $ITEM_ID || { echo 'set ITEM_ID' >&2; exit 2; }\n\nfor route in legacy candidate; do\n curl --silent --show-error --fail \\\\\n -H 'X-Route: '$route \\\\\n '$BASE_URL/catalog/items/'$ITEM_ID > $route.json\ndone\n\njq -S 'del(.requestId, .generatedAt)' legacy.json > legacy.normalized.json\njq -S 'del(.requestId, .generatedAt)' candidate.json > candidate.normalized.json\ndiff -u legacy.normalized.json candidate.normalized.json\nНулевой diff здесь проверяет один идентификатор, один момент и заранее названную нормализацию. Он не доказывает отсутствие побочного эффекта, равную производительность или корректность для других ролей. Добавьте случаи «идентификатор не найден», «нет прав», неверный формат, граничное количество и повтор. Не удаляйте из сравнения поле только ради зелёного результата: сначала объясните его владельцу.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
Оба пути вернули 200 | Сравнили транспорт, а не смысл | Проверить поля, ошибки, время и эффект | Добавить contract cases |
Отказ стал 500 | Потеряна категория ошибки | Повторить invalid case на одном состоянии | Сохранить внешний контракт или версионировать его |
| Повтор создаёт вторую запись | Не определено правило повтора | Проверить record id и журнал эффекта | Добавить idempotency key или оставить запись в legacy |
| Candidate медленнее только вечером | Разные нагрузка или состояние кеша | Сопоставить окно, population и backend | Повторить сравнение в сопоставимых условиях |
| После возврата появились дубли | Rollback маршрута не отменил запись | Сверить состояние и внешние вызовы | Остановить write-path и назначить reconciliation owner |
Маршрутизатор нужен в конкретном месте: он принимает решение, оставить запрос на legacy или передать его candidate. Для остальных операций действует прежнее правило. Если флаг разбросан по внутренним вызовам, один запрос может частично пройти по старому и частично по новому пути, и сравнение потеряет смысл. Логи должны различать как минимум маршрут, версию адаптера, корреляционный идентификатор и результат.
\nAWS описывает для strangler fig три фазы: transform — создать новую функциональность параллельно со старой, coexist — временно держать обе реализации и направлять трафик через прокси, eliminate — вывести старую часть после переноса. Это модель постепенного вытеснения, а не доказательство того, что конкретная миграция безопасна. Прокси может стать единой точкой отказа или узким местом, поэтому его latency и ошибки измеряют отдельно.
\nКанареечная подача тоже требует границ. Назовите population, контрольную группу, окно наблюдения, сигналы и владельца решения. «Десять процентов» не является универсальным порогом: редкий сценарий может не попасть в малую группу, а общая зависимость может одинаково испортить оба маршрута. Не расширяйте волну, если значение критичного сигнала неизвестно.
Для read-only операции возврат обычно означает смену правила маршрутизатора: следующие запросы идут в legacy. Но общий кеш, мигрированный справочник или sticky session могут связать два пути. До возврата проверьте, понимает ли старая реализация актуальное состояние и не изменял ли candidate его косвенно.
\nДля write-операции граница намного строже. Новый обработчик мог создать заказ, отправить сообщение или вызвать платёжного провайдера. Остановка candidate предотвращает часть следующих вызовов, но не удаляет запись во внешней системе. Компенсация может быть невозможной, породить второй эффект или потребовать решения бизнеса. Поэтому в карточке шва отдельно назовите route rollback, data recovery и владельца сверки.
Минимум для записи — идентификатор операции, журнал переходов, ключ идемпотентности или доказанное отсутствие повторной доставки, владелец сверки и процедура расхождения. Если этих данных нет, первый шов лучше сузить до чтения, добавить preview или оставить запись в legacy. Аварийный delete-скрипт без карты зависимостей не является планом восстановления.
\nStrangler-подход требует, чтобы внешний вызов можно было перехватить и маршрутизировать. AWS отдельно отмечает, что он не подходит маленьким системам с низкой сложностью, а прокси может стать bottleneck. Если запрос нельзя разделить по операции или нет способа быстро вернуть legacy, сначала нужна другая форма изменения — например, внутренний совместимый слой без переключения трафика.
\nCanary и сравнение ответов не дают статистической гарантии. Малая population может не содержать редкую роль, synthetic data не показывает реальные состояния, а общий кеш нарушает независимость control и candidate. Для денег, прав, персональных данных и внешних транзакций read-only схема из примера недостаточна: нужны threat model, аудит доступа, идемпотентность и сверка с владельцем данных.
\nУчебный TypeScript и shell выше не запускают ваш legacy, не измеряют его p95 и не доказывают результат в production. Они задают воспроизводимую форму проверки. Факты о конкретной системе берите из кода, тестов, логов и трасс выбранного контура; неизвестное оставляйте неизвестным, пока его не проверит владелец.
\nШов готов к ограниченному рассмотрению, когда другой инженер без устного контекста показывает один вход, один ожидаемый результат, список эффектов, owner решения, valid/invalid/repeat cases, источник каждого ожидания и проверенный legacy-маршрут. Для нового пути видны отдельные сигналы. Для возврата есть действие, а для необратимых данных отдельно описано, чего возврат не покрывает.
\nЕсли неизвестен владелец эффекта, нельзя восстановить исходное состояние или consumer скрыт, это не повод увеличивать процент трафика. Сузьте операцию, соберите доказательство или оставьте её на legacy. Безопасность первой волны означает не «новый сервис уже написан», а «расхождение можно обнаружить, остановить и объяснить».
\n