{ "index": 65, "slug": "editorial-2026-03-mechanism-data-contracts", "title": "Совместимость схемы — это направление, а не номер версии", "excerpt": "Как compatibility gate отделяет направление чтения, обязательные поля, объявленные additions и возможности consumer — и почему неопределённость должна останавливать проверку.", "contentHtml": "

Сбой на границе сервисов часто начинается с безобидного изменения: producer выпускает JSON с новым полем, а consumer продолжает читать его как прежний. Один reader игнорирует неизвестный ключ, другой отклоняет объект целиком. Если при этом поле state заменили на похожее phase, ошибка становится ещё дороже: данные формально похожи, но старый код больше не видит обязательное значение.

\n

Номер версии не отвечает на вопрос о совместимости. Нужно знать, кто пишет, кто читает, относительно какой схемы сравнивается изменение и какие правила действуют у reader. В этой статье разберём узкий compatibility gate: он сравнивает две зафиксированные формы, проверяет направление backward и останавливается, если поле, намерение producer или способность consumer неизвестны.

\n

Что означает совместимость

\n

В статье используются четыре роли. Producer формирует новую запись. Consumer получает её. Writer schema описывает форму, с которой записали данные, а reader schema — форму, которую ожидает читатель. Для простого JSON API эти понятия могут находиться в одном OpenAPI-документе, в коде валидатора и в договорённости команды, но логика вопроса остаётся той же.

\n

Назовём старую форму baseline, новую форму candidate, а направление backward определим так: reader, рассчитанный на baseline, должен принять запись candidate. Это локальное определение политики, а не универсальный смысл слова для всех платформ. Для проверки обратного сценария — новый reader читает старые данные — нужна отдельная relation. Совместимость не является симметричным boolean.

\n
Как читать направление изменения
ОтношениеЧто проверяемТипичный рискМинимальное решение
backwardСтарый reader принимает новую записьУдалено required-поле или reader отвергает новый ключСохранить обязательную поверхность и проверить policy reader
forwardНовый reader принимает старую записьНовый код ожидает поле, которого нет в старых данныхЗадать default, миграцию или период двойного чтения
fullОба отношения проходятОдно направление проверили, второе подразумевалиЗапустить две отдельные проверки и хранить их результаты
НеизвестноНельзя определить baseline, candidate или consumerЗелёный статус скрывает невыбранное отношениеОстановить сравнение и запросить недостающую связь
\n

Схема не равна смыслу

\n

JSON Schema действительно проверяет экземпляр по ограничениям вроде type, required и enum. Это полезный слой: валидатор может показать, что строка не стала числом или что обязательный ключ пропал. Но сама спецификация описывает валидность документа относительно схемы, а не направление миграции, список реальных consumer и значение слова active в конкретном бизнес-процессе.

\n

Есть и менее очевидная граница. В JSON Schema 2020-12 ключ format может быть аннотацией; обязательное assertion-поведение зависит от заявленного vocabulary и реализации. Поэтому format: date-time нельзя молча считать проверкой существования даты, часового пояса или корректности бизнес-операции. Эти свойства следует закрепить отдельным правилом приложения и проверять тем же reader, который будет использовать значение.

\n

В бинарных форматах правила могут быть другими. Avro сопоставляет writer schema и reader schema по своим правилам schema resolution. В Protocol Buffers номер поля участвует в wire format, поэтому удалённые номера нельзя бездумно переиспользовать. Эти документы полезны как официальные модели эволюции, но их ограничения нельзя переносить на произвольный JSON endpoint без проверки конкретного сериализатора.

\n
\"Схема
Проверка идёт от зафиксированного отношения к форме, описанию добавлений и возможностям конкретного consumer. Красная ветка означает, что неизвестность сохраняется как причина остановки.
\n

Четыре проверки перед положительным verdict

\n

Сначала зафиксируйте relation. В записи должны быть family, direction, baselineVersion, candidateVersion, producer и consumerId. Строка «совместимо с v2» недостаточна: неизвестно, с чьей точки зрения и для какого reader. Если любой идентификатор пуст, результат — остановка, а не допущение.

\n

Затем сохраните required surface. В выбранной политике backward каждое required-поле baseline должно остаться в candidate с совместимым типом. Переименование state в phase рассматривайте как удаление и добавление, пока старый consumer не адаптирован. Совпадение смысла в обсуждении не меняет фактического имени ключа.

\n

После этого сверяйте additions. Если candidate добавил priority, producer должен объявить его в manifest изменения. Нельзя выводить намерение из одного удачно прочитанного sample: фактический diff мог также содержать routingHint. Скрытое поле сначала нужно объяснить или убрать.

\n

Последним проверяйте capability reader. Tolerant reader пропускает дополнительные ключи, strict reader запрещает их. То, что поле optional у producer, не меняет настройки consumer автоматически. Один прошедший сервис не доказывает совместимость остальных readers; verdict нужно хранить для каждой названной пары.

\n

Воспроизводимый пример без внешних зависимостей

\n

Ниже — минимальный Node.js-скрипт. Он не обращается к registry, сети, базе или реальным сообщениям. Для воспроизводимости сохраните блок во временный файл compatibility-check.mjs и запустите node compatibility-check.mjs. Функция проверяет только требуемые поля, типы, объявленные additions и способность reader принять дополнительные ключи.

\n
const baseline = {\n  required: ['id', 'state'],\n  properties: { id: 'string', state: 'string' },\n};\n\nconst candidate = {\n  required: ['id', 'state'],\n  properties: { id: 'string', state: 'string', priority: 'number' },\n};\n\nconst relation = {\n  direction: 'backward',\n  family: 'orders',\n  baselineVersion: '1.0',\n  candidateVersion: '1.1',\n  producer: 'orders-api',\n  consumerId: 'billing-worker-v1',\n};\n\nfunction check({ baseline, candidate, relation, declaredAdded, acceptsAdditional }) {\n  const relationFields = ['direction', 'family', 'baselineVersion',\n    'candidateVersion', 'producer', 'consumerId'];\n  if (!relationFields.every((name) => relation[name])) {\n    return { status: 'STOP', reason: 'relation-is-incomplete' };\n  }\n\n  const missing = baseline.required.filter(\n    (name) => !candidate.required.includes(name),\n  );\n  const changedType = baseline.required.filter(\n    (name) => candidate.properties[name] !== baseline.properties[name],\n  );\n  if (missing.length || changedType.length) {\n    return { status: 'STOP', reason: 'required-surface-changed', missing, changedType };\n  }\n\n  const added = Object.keys(candidate.properties)\n    .filter((name) => !Object.hasOwn(baseline.properties, name));\n  const undocumented = added.filter((name) => !declaredAdded.includes(name));\n  if (undocumented.length) {\n    return { status: 'STOP', reason: 'addition-is-not-declared', undocumented };\n  }\n  if (added.length && !acceptsAdditional) {\n    return { status: 'STOP', reason: 'reader-rejects-additional-fields', added };\n  }\n  return { status: 'PASS', reason: 'synthetic-structural-check-only' };\n}\n\nconsole.log(check({\n  baseline, candidate, relation,\n  declaredAdded: ['priority'],\n  acceptsAdditional: true,\n}));\n// { status: 'PASS', reason: 'synthetic-structural-check-only' }
\n

У этого запуска четыре важных свойства. Отношение заполнено. id и state сохранились с теми же типами. priority назван в declaredAdded. Reader явно допускает дополнительные поля. Положительный результат поэтому означает только прохождение перечисленных структурных правил, а не разрешение на выпуск.

\n

Отрицательные случаи важнее зелёного примера

\n

Проверка считается полезной, когда она воспроизводимо останавливает опасные варианты. Для первого запуска удалите state из candidate.required. Ожидаемый результат: reason: 'required-surface-changed'. Скрипт не объявляет phase заменой, потому что имена и семантика не угадываются.

\n

Верните state, добавьте в candidate.properties поле routingHint: 'string', но не внесите его в declaredAdded. Ожидается addition-is-not-declared. Это проверяет разницу между фактической формой и намерением изменения.

\n

Наконец, оставьте только priority и замените acceptsAdditional: true на false. Ожидается reader-rejects-additional-fields. Так видно, почему capability относится к named consumer, а не к номеру версии producer. Для неполной relation удалите consumerId и ожидайте relation-is-incomplete.

\n

Порядок работы команды

\n
  1. Выберите одну contract family и сохраните baseline с обязательными полями, типами, enum, единицами измерения и примером.
  2. Опишите candidate как отдельную версию; разделите сохранённые, добавленные, удалённые и изменившие тип поля.
  3. Назовите направление и каждого затронутого consumer. Не заменяйте список readers словом «клиенты».
  4. Составьте manifest additions и сравните его с фактическим diff схем, DTO или OpenAPI-документов.
  5. Запустите положительный и отрицательные примеры: удалённое required-поле, новый ключ без manifest, strict reader и неизвестное направление.
  6. Проверьте отложенные данные: очередь, retry, кэш, архив и отключённый клиент могут доставить старую форму после публикации candidate.
  7. Сохраните для каждой пары status, reason и nextAction. Общий итог вычисляйте только после адресных результатов.
\n

Границы применимости

\n

Такой gate проверяет ограниченную структурную поверхность. Он не находит скрытых consumers, не доказывает, что владелец честно описал свои требования, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку, задержки, размер payload или откат. Для этих вопросов нужны contract tests, интеграционные тесты, наблюдаемость и план перехода.

\n

Он также не ловит семантическую несовместимость. Строка state=active может сохранить имя и тип, но начать означать «активна подписка» вместо «активен заказ». Аналогичный риск есть у времени, денег, идентификаторов и nullable-полей: нужно явно закрепить часовой пояс, валюту, масштаб, источник и правило округления. Формально валидный JSON не делает эти значения правильными.

\n

Нельзя переносить этот результат между технологиями без адаптации. Для Avro нужно учитывать schema resolution, для protobuf — номера и reserved-поля, для JSON — поведение конкретного parser и настройку дополнительных ключей. Если схема, reader policy или направление неизвестны, единственно честное решение — STOP с запросом к владельцу контракта.

\n

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

\n

Пара готова к следующему этапу, когда зафиксированы baseline, candidate, direction и named consumer; обязательная поверхность не разрушена; все additions объявлены; capability reader подтверждена; пройдены положительный и отрицательные случаи; накопленные старые данные учтены. Report должен объяснять причину и следующее действие, а не сводить разные ситуации к проценту «совместимости».

\n

Если проверка не различает backward и forward, принимает пустой consumerId или пропускает новое значение enum только потому, что тип остался строковым, она отвечает не на тот вопрос. Номер 1.1 может помочь найти две точки сравнения, но не заменяет их содержимое и policy чтения.

\n

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

\n" }