{ "index": 143, "slug": "editorial-2024-01-mechanism-legacy-modernization", "title": "Модернизация legacy без ложной совместимости: как проверять поведение", "excerpt": "Одинаковый HTTP-ответ не доказывает совместимость старой и новой реализации. Разбираем контракт, побочные эффекты, повтор запроса и минимальную проверку перед переключением маршрута.", "contentHtml": "
После замены старого обработчика команда получает знакомый симптом: новый endpoint отвечает тем же статусом и похожим JSON, но клиент ломается на повторном запросе. В одном случае validation-ошибка превращается в 500. В другом preview начинает публиковать событие. В третьем повтор той же операции создаёт вторую запись. Цена ошибки — не только откат релиза. Команда уже меняет данные, отправляет уведомления или теряет доверие к ответу, а по snapshot это не видно.
\nПроблема начинается раньше кода. Команда называет совместимостью сходство сериализованного ответа. Но legacy-контракт включает больше: вход, категорию ошибки, обязательные поля, момент чтения состояния, запись, публикацию и правила повтора. Если эти свойства не названы до миграции, проверка после переключения превращается в спор о том, что считать регрессией.
\nМодернизация legacy безопаснее, когда команда выбирает один наблюдаемый шов и описывает его до замены. Шов — это конкретная операция между потребителем и старой реализацией. У него есть вход, выход, состояние, побочный эффект и владелец решения. Новый adapter может менять язык, библиотеку и внутреннюю структуру. Он не должен молча менять свойства, на которые опирается consumer.
\nParity здесь означает не побайтное равенство. Она означает совпадение заранее выбранных инвариантов. Для read-only preview важны статус, обязательные поля и отсутствие публикации. Для записи важны idempotency key, порядок эффекта и состояние после повтора. Для платежа добавляются денежная точность, авторизация и reconciliation. Один общий список проверок не подходит всем операциям.
\nРазделите контракт на четыре слоя. Transport описывает метод, путь, статус и значимые заголовки. Payload описывает типы, обязательные поля, значение null и отсутствие поля. Effect описывает запись, публикацию, очистку cache и запрет повторного действия. Time описывает, в каком состоянии читаются данные и что означает «тот же запрос»: тот же input, business key или idempotency key.
\nOpenAPI помогает зафиксировать первый и часть второго слоя. Он делает видимыми paths, operations, схемы и ответы. Но одинаковая схема не говорит, записал ли обработчик событие, когда он прочитал баланс и что произойдёт при повторе. Поэтому schema — это граница формы, а не сертификат поведенческой совместимости.
\ntype CompatibilityCase = {\n name: 'valid' | 'invalid' | 'repeat';\n precondition: string;\n request: unknown;\n expected: {\n statusCategory: string;\n fields: string[];\n effect: 'none' | 'one' | 'same-key-no-duplicate';\n };\n evidence: string;\n};\n\nconst repeatCase: CompatibilityCase = {\n name: 'repeat',\n precondition: 'same idempotency key, known initial state',\n request: { amount: 1000, idempotencyKey: 'case-42' },\n expected: {\n statusCategory: 'success-or-replayed-success',\n fields: ['operationId', 'status'],\n effect: 'same-key-no-duplicate'\n },\n evidence: 'response plus effect log in the test environment'\n};\nЭто учебный TypeScript-пример. Он показывает форму записи, но не вызывает endpoint и не доказывает, что повтор безопасен. Значение поля evidence должно ссылаться на реально доступный журнал, тестовую базу или другой наблюдаемый источник. Если источник ещё не подключён, результат нельзя помечать как parity passed.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Одинаковый JSON, но повтор создаёт запись | Сравнили payload и не проверили effect | Повторить запрос с тем же ключом и проверить журнал эффекта | Добавить idempotency rule или оставить операцию на legacy |
| Невалидный ввод получил 500 | Новая реализация потеряла категорию ошибки | Сопоставить status category и поля ошибки для invalid case | Сохранить внешний error contract либо версионировать API |
| Результаты расходятся только утром | Различается время чтения состояния или часовой пояс | Повторить case на фиксированном состоянии и записать timestamp | Явно определить time boundary и источник времени |
| Новый ответ содержит дополнительное поле | Совместимое расширение принято без проверки consumer | Проверить парсеры старых клиентов и правило unknown fields | Оставить поле optional или подготовить migration path |
| Команда не знает, какое отличие важно | У контракта нет владельца и инвариантов | Назначить owner и классифицировать каждое расхождение | Остановить расширение шва до решения владельца |
Сначала присвойте различию класс. Cosmetic — изменение, от которого не зависит consumer: например, пробел в сообщении или порядок незначимых ключей. Compatible extension — дополнительное optional-поле, которое старый клиент по договору игнорирует. Behavioral mismatch — другой статус, обязательное поле, значение, момент чтения или эффект. Unknown — различие обнаружено, но его значение ещё не установлено.
\nUnknown нельзя считать совместимым по умолчанию. Если неизвестное поле влияет на сумму, доступ, уведомление или повтор, новый путь не готов. Нужна проверка или решение владельца. Иногда старое поведение выглядит как ошибка, но на него уже опирается клиент. Тогда есть три честных варианта: временно сохранить поведение, выпустить новый контракт с миграцией или отложить замену. Нельзя назвать bugfix совместимостью только потому, что он кажется правильнее.
\nНачните с трёх случаев, но не принимайте их за полное покрытие. Valid показывает основной результат и обязательные поля. Invalid проверяет категорию отказа и отсутствие недопустимого эффекта. Repeat проверяет одинаковый ключ или вход и ожидаемое действие. Для каждого случая запишите precondition, request, expected result, место наблюдения и owner.
\nЕсли операция зависит от внешнего provider, курса, очереди или времени, это часть case. Зафиксируйте состояние, которое можно воспроизвести, или пометьте свойство неизвестным. Snapshot ответа подходит для payload. Для effect он недостаточен: два пути могут вернуть одинаковый JSON, но только один отправить сообщение. Здесь нужен журнал, счётчик, тестовая запись или иной источник, который действительно видит эффект.
\nНе каждый шов следует переносить первым. Остановите замену, если неизвестен владелец эффекта, нельзя получить начальное состояние, consumer скрыт, а различие касается денег, доступа или публикации. Остановите её также, если rollback возвращает маршрут, но не объясняет судьбу уже созданных данных. В таком случае проблема не в недостатке тестов. Сначала нужна граница данных и решение о reconciliation.
\nНе пытайтесь закрыть неизвестность большим snapshot или процентом «покрытия parity». Одно число смешивает критичный платёж и косметическую подпись. Полезнее список незакрытых классов: time-dependent, effectful, external-provider, access-denied. Для каждого выберите действие: проверить следующим, оставить на legacy или изменить контракт с версией.
\nOpenAPI описывает интерфейс, но не бизнес-смысл. Три case задают стартовую границу, но не покрывают всю систему. Учебный код выше не читает legacy, не запускает трафик, не сравнивает базу и не измеряет производительность. Поэтому статья не утверждает сохранение поведения какой-либо production-системы. Реальная готовность требует evidence из конкретного тестового или staging-контура.
\nШов готов к ограниченному переключению, когда owner назван, четыре слоя контракта заполнены, valid/invalid/repeat воспроизводимы, каждое обязательное различие классифицировано, effect наблюдаем, а возврат маршрута проверен отдельно от восстановления данных. Критический unknown должен отсутствовать или иметь явно принятое решение оставить операцию на legacy. Только тогда команда может объяснить, что именно она перенесла и какую цену изменения согласовала.
\n