DarkRiDDeR14 мин

Совместимость при модернизации legacy: contract и parity без ложного равенства

АрхитектураAPILegacy

Симптом после первой замены обманчив: старый и новый endpoint возвращают одинаковый status, JSON похож, demo проходит. Через неделю клиент повторяет запрос, а в новом пути появляется второй effect, теряется категория validation-ошибки или меняется порядок значимых полей. Команда спорит, достаточно ли похоже, потому что до разработки не зафиксировала, что означает parity. Цена — регрессия, которую нельзя локализовать: ответ уже ушёл consumer, а причина спрятана между transport, domain rule и побочным действием.

Parity не требует сравнивать каждый байт. Он требует заранее выбрать свойства, для которых различие недопустимо. У операции preview важно, что invalid input не выглядит как successful quote, обязательные поля не исчезают и repeat не создаёт объявленный effect дважды. Для другой операции важнее порядок событий или конкретная кодировка ошибки. Список выбирает владелец доменного контракта, а не test framework. Если свойства названы после расхождения, это расследование, а не доказательство совместимости.

Форма API — полезная, но неполная граница

OAS 3.1.0 определяет способ описать HTTP-интерфейс без доступа к исходному коду и трафику: paths, operations, request/response structures, schemas. Такой документ помогает сделать видимыми обязательные поля, статусы и версию. Для перехода это сильный первый слой: consumer и implementer перестают угадывать форму. Но OAS не обещает, что два одинаковых документа выполняют одинаковую бизнес-операцию. Schema Object описывает структуру, а значение статуса, порядок effects, правило повтора и момент чтения состояния остаются в договоре команды.

Compatibility contract лучше разделить на четыре слоя. Transport — method, path, status category и headers, если они значимы. Payload — обязательные поля, type, семантика null/absence и порядок там, где consumer зависит от него. Effect — что объявлено как запись, публикация сообщения, cache invalidation или запрет повтора. Time — что значит тот же input: идентичный request, idempotency key, business key или момент состояния. Один test snapshot почти всегда покрывает только часть transport и payload.

Матрица parity: что сравнивать и что не обещать
СлойИнвариантEvidence для реальной проверкиОпасная подмена
Transportsuccess и validation не меняют категорию statusrecorded request/response из согласованного контурасравнить только HTTP 200
Payloadобязательные поля и их смысл сохраненыcontract case с expected resultсравнить размер JSON или порядок всех ключей
Effectrepeat не дублирует объявленное действиетрасса, журнал или тест с доступом к effectрешить по одинаковому ответу
Timeсостояние читается в оговорённый моментscenario с известной подготовкой данныхназвать любой repeat тем же input
Ownershipизменение правила утверждает domain ownerdecision record и reviewдать parser-у схемы право решать semantics
Матрица совместимости сопоставляет три synthetic cases с четырьмя слоями contract: transport, payload, effect и time. В каждой клетке требуется declared evidence; строка unknown не превращается в зелёную parity-отметку.
Диаграмма не содержит ответов старого или нового сервиса и не запускает parity test. Она показывает, какие вопросы должны иметь отдельное доказательство.

Сначала классифицировать различие, затем выбирать реакцию

Не всякое различие означает дефект, но любое различие должно получить класс. Cosmetic — пробел, порядок незначимых ключей, новый optional label, если consumer действительно от него не зависит. Compatible extension — дополнительное поле, которое old consumer игнорирует по документированному правилу. Behavioral mismatch — изменился status, обязательное поле, validation, amount, idempotency или effect. Unknown — наблюдение есть, но команда пока не знает, значимо ли поле или порядок. Только первые два случая можно принять без расширения scope, и то после evidence о consumer.

Классификатор защищает и от обратной крайности — попытки скопировать legacy bug без вопроса. Иногда старое поведение выглядит ошибкой, но клиент уже построил вокруг него workflow. Тогда существуют три решения: сохранить bug временно ради compatibility, изменить contract с версией и migration path или остановить замену до согласования. Нельзя решить это выражением expected equals actual. Оно отвечает на синтаксическое равенство и ничего не говорит о том, кто понесёт цену изменения. M7 здесь — не больше тестов, а явная развилка с owner и стоимостью каждого варианта.

Три case лучше ста невыбранных

Для первого шва достаточно трёх cases. Valid case показывает основной результат с объявленными полями. Invalid case проверяет, что новый путь не превращает ошибку в success или generic 500. Repeat case покрывает повтор с тем же ключом или input и объясняет ожидаемый effect. Это не test strategy всей системы. Это минимальный набор, который заставляет команду назвать поведение в местах, где одинаковый JSON маскирует риск. После реального расхождения добавляется ещё один case с источником evidence; не нужно заранее строить каталог из сотни случайных fixtures.

Каждый case содержит не только input и expected output. Добавьте precondition, источник данных, критерий сопоставления и границу. Если outcome зависит от времени, курса или внешнего provider, отметьте это. Если effect нельзя наблюдать без доступа к журналу, не пишите «эффект совпадает». Напишите unknown и спланируйте доступ или изоляцию. Неприятная ячейка unknown полезнее зелёного чекбокса: она показывает, что новый путь не готов к этому классу трафика, а не прячет отсутствие прав за термином parity.

Почему snapshot и schema не спасают от side effect

Snapshot полезен как evidence формы, если в нём есть версия contract и понятная подготовка данных. Он плохо отвечает на вопрос, что произошло после ответа. Два обработчика могут вернуть один payload, но один записывает только preview, а второй публикует событие. Здесь parity не в копировании всей базы, а в конкретном инварианте effect: в preview нет publication, repeat с тем же ключом не создаёт вторую запись, failed validation не меняет status. Для каждого инварианта нужен способ наблюдения в реальном test/staging контуре; fixture этой статьи специально такого способа не имеет.

То же относится к error mapping. Новый язык, library или gateway может упростить исключения до internal error. Для consumer это не техническая деталь, если он различает validation, retryable и forbidden. Сначала фиксируется внешняя категория и необходимый payload, затем внутренняя реализация может меняться. Это дешевле, чем сохранять stack trace и текст legacy-ошибки. Но нельзя назвать категории совместимыми только потому, что они логичнее. Нужны owners и evidence, что потребитель не зависит от старой формы.

Учебный fixture запрещает ложное доказательство

Fixture пакета создаёт три synthetic cases: valid, invalid и repeat. У каждого observed равно not-executed; у contract realParity равно not-executed-or-claimed. Код отвергает case с real-test-passed, попытку назвать parity executed, урезанный список effect-rules и claim coverage. Это не mock endpoint и не contract test framework. Его задача — показать, что decision record не должен объявлять green parity, пока он не содержит отдельного запуска и evidence из доступной среды.

node web/scripts/upgrade-2024-01.mjs --verify-fixture

PASS означает, что immutable synthetic record удерживает три вида rules, три case и границы доказательства. Он не читает legacy code/history, не вызывает API, не запускает test runner, CI или сеть и не смотрит на coverage или latency. У него нет customer data, database state, времени и очередей. Он не сообщает, сохранён ли порядок вызовов, как consumer обрабатывает new error или совпали ли реальные результаты. Для этой статьи это защита от красивого, но пустого слова parity.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Новый путь возвращает похожий payload, но команда не объясняет разницу при invalid input или repeat.
  2. Причина. Contract зафиксировал schema, но не semantics, effect и time. Проверка сравнивает сериализацию вместо обещания consumer-у.
  3. Проверка contract. Разложите операцию на transport, payload, effect и time. Для слоя оставьте различия, от которых зависит известный consumer.
  4. Проверка evidence. Для valid, invalid и repeat укажите источник подготовки, место наблюдения и owner. Unknown остаётся unknown до проверки.
  5. Действие. Behavioral mismatch блокирует расширение шва; compatible extension требует record; cosmetic отличие фиксируется вместе с причиной независимости consumer.
  6. Повтор. После расхождения добавляйте один case и один критерий, а не переписывайте contract задним числом под текущий implementation.

Как не превратить parity в бесконечный проект

Scope parity заканчивается на выбранном шве. Если для него понадобилось доказать поведение десяти соседних команд, это сигнал отступить и выбрать более тонкую границу или сначала формализовать dependency contract. Замерять «процент parity coverage» без определения веса cases бессмысленно: одно число поставит рядом основной платёжный сценарий и косметическую подпись. Вместо этого полезнее назвать незакрытые классы: time-dependent, effectful, external-provider, access-denied. Каждый получает решение: взять следующим, оставить на legacy или согласовать отдельную migration.

Parity не отменяет эволюцию. Когда продукту нужна новая semantics, старая и новая операции расходятся явно: новый version contract, новая operation или назначенный период coexistence. Самый дорогой вариант — спрятать изменение внутри migration и надеяться, что все consumers воспримут его как bugfix. Тогда команда не может ни проверить, что сохранила legacy, ни объяснить, почему изменила его. Contract ценен тем, что делает выбор проверяемым и обсуждаемым до rollout.

Ограничения и следующий шаг

Эта статья не утверждает, что OpenAPI покрывает business behavior, что трёх cases достаточно для всех доменов или что существующий legacy bug надо сохранять. В ней нет production data, readiness CI, отчёта coverage, замера performance и результата parity test. Источники доступны к январю 2024: OAS 3.1.0 описывает interface, Fowler — риск cut-over, Google — оценку ограниченного rollout. Они не поставляют готовую матрицу вашей команды и не дают автоматическую власть над архитектурным решением.

Следующий шаг: для одного шва заполните матрицу из четырёх слоёв и трёх cases. В каждой строке добавьте owner, evidence source и одно из значений compatible, mismatch, unknown. Не пишите общий verdict parity passed, пока unknown свойство может изменить effect или категорию ошибки. Когда карта готова, она позволяет выбрать цену: сохранить старое временно, версионировать новое или отложить rollout. Это точнее, чем спорить о близости двух JSON.

Историческая граница января 2024

К январю 2024 OAS 3.1.0 уже была стабильной спецификацией 2021 года, исходная заметка Fowler датирована 2004 годом, а Workbook Google — изданием 2018 года. Текст не ссылается на поздние contract-платформы и не имитирует результаты чужих parity runs. M7 проявляется в разделении стоимости compatibility, owner и критериев решения; прагматичный тон не заменяет реальное evidence схемой.

Проверяемые источники

  • Martin Fowler: Original Strangler Fig Application, 29.06.2004 — Первичный текст автора метафоры: критическое cut-over переписывание оказывается сложнее ожидаемого и рискованно; постепенное вытеснение может раньше дать ценность. Это не готовая инструкция для чужого домена, базы данных или маршрутизатора.
  • OpenAPI Specification v3.1.0, 15.02.2021 — Официальная спецификация описывает language-agnostic интерфейс HTTP API, paths, operations и Schema Object. Она помогает фиксировать форму интерфейса, но не доказывает runtime parity, порядок эффектов или поведение неизвестного legacy-модуля.
  • Google SRE Workbook: Canarying Releases, copyright 2018 — Официальная глава определяет canary как частичное и ограниченное по времени развёртывание с оценкой перед продолжением, требует сравнивать canary и control и называет границы synthetic load. Она не задаёт процент, метрики, полномочия или rollback для этого пакета.