{ "index": 10, "slug": "editorial-2027-09-field-mentor-series", "title": "API-diff в code review: как найти несовместимость и сохранить откат", "excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.", "contentHtml": "

Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.

\n

Тезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.

\n

Механизм: контракт живёт по обе стороны границы

\n

API состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.

\n

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

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый клиент не создаёт ресурсВ запрос добавили required-полеНайти builders, SDK и fixtures старой версииОставить поле optional, задать совместимое значение или выпустить версию
Клиент падает на успешном ответеУдалили свойство или изменили типПоиск чтения свойства и contract-тест старого клиентаСначала deprecated-окно и новое поле, затем удаление
Новая ветка обработки не срабатываетСузили enum или добавили неизвестное значениеПрогнать значения через старые switch и парсерыСохранить старые значения либо сменить версию
Откат приложения не восстанавливает работуНовый сервер записал только новый форматПроверить чтение старой версией после частичного rolloutСначала expand, затем switch, потом contract
Тесты зелёные, внешний потребитель сломанПотребитель не попал в репозиторийПроверить registry, документацию, логи и владельцев интеграцийОстановить удаление и оставить совместимый путь
\n

Пример: классификатор как первая страховка

\n

Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.

\n
function classifyApiChange(change) {\n  const removed = Array.isArray(change.removedProperties)\n    ? change.removedProperties\n    : [];\n  const required = Array.isArray(change.addedRequiredProperties)\n    ? change.addedRequiredProperties\n    : [];\n  const narrowedEnum = Boolean(change.narrowedEnum);\n  const breaking = removed.length > 0\n    || required.length > 0\n    || narrowedEnum;\n\n  return {\n    status: breaking ? 'breaking' : 'compatible',\n    action: breaking\n      ? 'version-or-expand-compatibility-window'\n      : 'run-consumer-contract-tests',\n  };\n}\n\nconst result = classifyApiChange({\n  removedProperties: ['displayName'],\n  addedRequiredProperties: [],\n  narrowedEnum: false,\n});\n\nconsole.log(result);\n// { status: 'breaking',\n//   action: 'version-or-expand-compatibility-window' }
\n

Функция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с additionalProperties: false или сравнением полного JSON может сломаться даже на расширении. Статус compatible здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».

\n

Обратимость: сначала данные, потом бинарник

\n

Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только displayNameV2, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.

\n

На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.

\n
Маршрут API review: классификация diff, проверка потребителей и окно обратимой миграции перед удалением старой формы.
Схема связывает локальный diff с потребителями и данными. Если старый формат ещё нужен, путь к удалению должен остановиться.
\n

Порядок действий для одного изменения

\n
  1. Назовите endpoint, метод, статус, media type и версию клиента. Не смешивайте request, response и внутреннюю модель в одном описании.
  2. Снимите старую и новую формы. Выпишите required-поля, типы, nullable, enum, значения по умолчанию и изменение смысла.
  3. Запустите классификацию. Для каждого breaking-признака укажите конкретное поле и потребителя, которого он затрагивает.
  4. Найдите потребителей по сгенерированным типам, сериализаторам, SDK, fixture, документации, очередям и логам. Отдельно отметьте внешние интеграции, которых нет в монорепозитории.
  5. Составьте матрицу чтения и записи: старая версия против старой формы, старая против новой, новая против старой и новая против новой. Добавьте частичный rollout и повторную доставку события.
  6. Напишите отрицательный contract-тест для старого клиента и положительный тест для нового. Ошибка должна называть поле, значение и ожидаемую форму.
  7. Выберите способ перехода: совместимое расширение, новая версия или expand/contract. Для rollout запишите точку остановки и способ возврата.
  8. Удаляйте старую форму только после измеримого сигнала: старый consumer не обращается к полю, истёк согласованный срок хранения, а восстановление проверено.
\n

Где автоматическая проверка не помогает

\n

Классификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.

\n

Схема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде If-Match, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.

\n

Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.

\n

Критерий готовности

\n

Изменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.

\n

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

" }