{ "index": 66, "slug": "editorial-2026-03-practice-data-contracts", "title": "Изменение схемы без устных договорённостей: как проверить контракт данных", "excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.", "contentHtml": "

Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки — не только ошибка парсинга: в очереди остаются сообщения, повторная доставка увеличивает нагрузку, а поиск владельца контракта съедает время.

\n

Чтобы не обсуждать совместимость на уровне догадок, нужно проверить одну конкретную пару: какая форма была исходной, какая стала новой, кто пишет данные и какой reader их читает. В этой статье разберём маршрут для JSON-подобного объекта. Он не заменяет интеграционные тесты и не объявляет изменение безопасным для неизвестных клиентов.

\n

Главный вопрос: кто читает новую запись

\n

Версия v1.1 сама по себе ничего не гарантирует. Старый reader может игнорировать незнакомые поля, а может использовать строгую проверку и отклонять их. Один и тот же candidate поэтому совместим с одним consumer и несовместим с другим.

\n

Дальше под backward compatibility будем понимать одно проверяемое направление: старый reader получает запись, созданную новой схемой. Это определение относится только к выбранной паре. Оно не доказывает обратное направление, совместимость SDK, сохранённых сообщений или других потребителей.

\n

Сначала зафиксируйте четыре артефакта

\n

Baseline — форма, которую уже принимает named consumer. Candidate — полная форма после изменения. Manifest — явный список добавленных, удалённых и изменённых полей. Наконец, поведение consumer — правила его парсера: допускает ли он дополнительные ключи и как обрабатывает неизвестные значения.

\n

Нельзя подменять baseline коротким описанием вроде «добавили priority». Если одновременно изменились тип amount, enum state или единицы измерения, короткая заметка скроет breaking change. Полный candidate и diff должны быть доступны тому, кто будет читать запись после выпуска.

\n
\"Схема
Проверяется не только новая схема, но и её отношение к известному reader. Неописанное поле routingHint останавливает поток до передачи изменения в review.
\n

Минимальный воспроизводимый gate

\n

Ниже — самостоятельный пример на Node.js без сторонних пакетов. Сохраните код в файл contract-gate.mjs и запустите командой node contract-gate.mjs. Внутренняя модель намеренно мала: она проверяет имена, типы, обязательность, manifest и способность consumer принимать дополнительные ключи.

\n
const baseline = {\n  id: { type: 'string', required: true },\n  state: { type: 'string', required: true },\n  note: { type: 'string', required: false },\n};\n\nconst additive = {\n  ...baseline,\n  priority: { type: 'integer', required: false },\n};\n\nconst manifest = {\n  direction: 'backward',\n  producer: 'work-item-api',\n  consumer: 'billing-worker-v1',\n  added: ['priority'],\n  removed: [],\n  changed: [],\n};\n\nfunction sameList(left, right) {\n  return [...left].sort().join('|') === [...right].sort().join('|');\n}\n\nfunction review({ baseline, candidate, manifest, consumer }) {\n  if (manifest.direction !== 'backward' || !manifest.producer || !manifest.consumer) {\n    return { status: 'stop-missing-contract-context' };\n  }\n\n  const baselineNames = Object.keys(baseline);\n  const candidateNames = Object.keys(candidate);\n  const added = candidateNames.filter((name) => !baseline[name]);\n  const removed = baselineNames.filter((name) => !candidate[name]);\n  const changed = baselineNames.filter((name) => {\n    if (!candidate[name]) return false;\n    return baseline[name].type !== candidate[name].type\n      || baseline[name].required !== candidate[name].required;\n  });\n  const requiredLost = removed.filter((name) => baseline[name].required);\n\n  if (requiredLost.length > 0) {\n    return { status: 'stop-required-field-removed', fields: requiredLost };\n  }\n  if (changed.length > 0) {\n    return { status: 'stop-field-definition-changed', fields: changed };\n  }\n  if (!sameList(added, manifest.added)\n    || !sameList(removed, manifest.removed)\n    || !sameList(changed, manifest.changed)) {\n    return { status: 'stop-manifest-mismatch', actual: { added, removed, changed } };\n  }\n  if (added.length > 0 && !consumer.acceptsAdditional) {\n    return { status: 'stop-consumer-rejects-additional-fields' };\n  }\n  return { status: 'compatible-for-named-reader', actual: { added, removed, changed } };\n}\n\nconsole.log(review({\n  baseline,\n  candidate: additive,\n  manifest,\n  consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));\nconsole.log(review({\n  baseline,\n  candidate: additive,\n  manifest,\n  consumer: { name: 'strict-billing-worker-v1', acceptsAdditional: false },\n}));\nconsole.log(review({\n  baseline,\n  candidate: { id: baseline.id, note: baseline.note, phase: { type: 'string', required: true } },\n  manifest: { ...manifest, added: ['phase'], removed: ['state'] },\n  consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));
\n

У первого вызова результат compatible-for-named-reader: обязательные поля сохранились, priority попал в manifest, а consumer принимает дополнительные ключи. У второго тот же candidate отклоняется, потому что strict consumer запрещает новый ключ. Третий вызов останавливается на удалённом обязательном state. Это три разных решения для почти одинакового diff.

\n

Пример можно проверить на чистой машине с Node.js 18 или новее: node --version покажет установленную версию, а node contract-gate.mjs выведет три объекта в консоль. Скрипт не обращается к сети, registry или вашему сервису, поэтому его положительный результат относится только к указанным данным.

\n

Как читать результат проверки

\n
РезультатЧто установленоСледующий шагГраница вывода
compatible-for-named-readerСхемы и manifest совпали, reader допускает additionЗапустить contract/integration tests и проверить rolloutНеизвестные consumer и семантика значений не проверены
stop-required-field-removedВ candidate исчезло обязательное поле baselineСохранить поле на период миграции или версионировать контрактПереименование не исправляется номером версии
stop-consumer-rejects-additional-fieldsReader не принимает добавленные ключиИзменить reader, изолировать новый контракт или дождаться миграцииСвойство optional в схеме не меняет parser policy
stop-manifest-mismatchФактический diff шире заявленногоИсправить candidate или manifest и повторить проверкуGate не выясняет смысл незаявленного поля
stop-field-definition-changedИзменился тип или признак обязательностиСделать отдельный migration plan и тесты значенийДаже тот же JSON-тип может скрывать смену единиц
\n

Почему одной проверки схемы мало

\n

JSON Schema отвечает на вопрос о валидности экземпляра относительно набора ограничений. В спецификации 2020-12 есть структурные ключевые слова type, required и additionalProperties. Они помогают описать форму объекта и правила дополнительных ключей, но не знают, какой сервис владеет полем и какой reader будет обрабатывать запись.

\n

Это видно и по JSON Type Definition (JTD). RFC 8927 разделяет properties и optionalProperties, а режим дополнительных свойств задаётся отдельно. Такой словарь дисциплинирует схему, но не выбирает срок поддержки старой формы и не проверяет ваш parser.

\n

В Apache Avro терминология writer schema и reader schema встроена в механизм разрешения схем. Спецификация описывает, как reader сопоставляет поля, что происходит с отсутствующим полем и когда возникает ошибка. Для обычного JSON API правила Avro автоматически не применяются, но сам способ постановки вопроса полезен: всегда указывайте обе стороны чтения и записи.

\n

Структурное изменение и изменение смысла

\n

Добавление необязательного поля часто проще, чем удаление обязательного. Но это не универсальное правило. Строгий parser, подписанный payload, whitelist ключей или downstream-система с фиксированным форматом могут отклонить additive change.

\n

Опаснее всего изменение смысла без изменения типа. amount мог быть суммой в рублях, а стал суммой в копейках. Строковый timestamp мог перейти из локального времени в UTC. Enum state=ready мог означать «готов к отправке», а после изменения — «готов к оплате». Structural diff такие изменения не докажет. В manifest нужны единицы, timezone, допустимые значения и ссылка на владельца доменного смысла.

\n

Если контракт использует JSON Schema, структурную валидацию можно выполнять отдельно, например через выбранный валидатор в CI. Его настройки должны быть зафиксированы: разные библиотеки могут по-разному трактовать форматные аннотации. Проверка format: date-time не заменяет проверку бизнес-часового пояса и срока действия события.

\n

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

\n
  1. Назовите пару. Запишите producer, конкретного consumer и направление проверки. Слова «все клиенты» недостаточно.
  2. Снимите baseline. Сохраните идентификатор схемы, обязательные поля, типы, enum, единицы и правила дополнительных ключей.
  3. Опишите candidate. Покажите полную новую форму. Не редактируйте baseline задним числом, иначе diff потеряет исходную точку.
  4. Составьте manifest. Перечислите added, removed и changed. Для изменения смысла добавьте текстовое правило и владельца.
  5. Проверьте структуру. Сверьте required-поля и типы. Отдельно проверьте зависимости полей, enum и дополнительные ключи.
  6. Запустите отрицательные тесты. Удалите обязательное поле, добавьте незаявленный ключ, включите strict consumer и измените тип. Каждый сценарий должен остановиться с понятной причиной.
  7. Проверьте реальный reader. Выполните contract test на версии consumer, которая будет читать запись после rollout. Учебный gate не заменяет такой тест.
  8. Согласуйте удаление. Для breaking change укажите период dual-read/dual-write, миграцию сохранённых сообщений и условие, при котором старое поле можно убрать.
  9. Наблюдайте выпуск. После deploy сравните ошибки валидации, долю отвергнутых сообщений и lag очереди с baseline. При росте ошибок остановите rollout.
\n

Ограничения применимости

\n

Описанный gate проверяет только одну форму объекта и одного named consumer. Он не обнаружит неизвестных внешних клиентов, старые записи в очереди, кэшированные ответы, сгенерированные SDK, схемы в базе или трансформации промежуточного сервиса. Для этого нужен инвентарь потребителей и тесты на реальные границы системы.

\n

Положительный результат не является самостоятельным разрешением на deploy. Нужны проверка авторизации, размер payload, подпись, порядок событий, повторная доставка, таймауты и наблюдаемость. Эти свойства не следуют из JSON Schema и не выводятся из номера версии.

\n

Наконец, не называйте изменение backwards-compatible, если вы проверили только новый reader на старой записи. Это другое направление. Если продукт требует оба направления, проведите две отдельные проверки и запишите их результаты в manifest.

\n

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

\n

Перед review другая команда должна без устного пояснения ответить на пять вопросов: какая форма является baseline, что изменилось в candidate, совпадает ли фактический diff с manifest, кто читает новую запись и какое правило дополнительных ключей действует у reader. Если хотя бы один ответ неизвестен, результат проверки — остановка и уточнение контракта.

\n

Такой порядок не делает изменение автоматически безопасным. Он делает риск видимым: структурную ошибку можно поймать до выпуска, несовместимый parser — проверить на named consumer, а смену бизнес-смысла — вынести в отдельное решение. Именно эта граница превращает сообщение «мы добавили одно поле» в проверяемое инженерное изменение.

\n

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

" }