{ "index": 64, "slug": "editorial-2026-03-field-data-contracts", "title": "Контракты данных: как не принять похожее поле за совместимое", "excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем смысл полей, политику reader и fail-closed проверку producer → consumer.", "contentHtml": "

После изменения JSON один сервис продолжает читать сообщения, и команда ставит контракту зелёный статус. Затем другой consumer начинает отбрасывать тот же объект: он запрещает неизвестные поля. Третий ждёт status: paid как признак завершённой оплаты, а producer использует paid для промежуточного состояния после авторизации. Форма объекта похожа, sample проходит, а решение всё равно ломает процесс.

\n

Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары producer → consumer, версии, направления передачи и набора правил чтения.

\n

Что именно является контрактом

\n

Контракт данных — это не перечень ключей. Он отвечает как минимум на пять вопросов: какие поля обязательны, какие типы и единицы измерения допустимы, какие значения имеют деловой смысл, разрешены ли дополнительные поля и как долго сообщение остаётся читаемым. Если в карточке изменения написано только «добавили status», consumer не получил достаточного описания.

\n

В учебном кейсе producer отправляет заказ. В первой версии поле status означает жизненный цикл заказа: new, paid, cancelled. Другой сервис использует такое же имя для результата платежной операции: authorized, captured, refunded. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.

\n
\"Матрица
Одна candidate schema даёт разные результаты для разных reader. Поэтому verdict относится к названной связи, а не ко всей системе без исключений.
\n

Сначала разделяем форму и смысл

\n

Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.

\n

JSON Schema хорошо описывает первый слой. В спецификации есть type, required, enum и additionalProperties. Но сама структурная проверка не знает, означает ли paid захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.

\n
{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\"orderId\", \"status\", \"occurredAt\"],\n  \"properties\": {\n    \"orderId\": {\"type\": \"string\", \"minLength\": 1},\n    \"status\": {\"enum\": [\"new\", \"paid\", \"cancelled\"]},\n    \"occurredAt\": {\"type\": \"string\", \"format\": \"date-time\"}\n  }\n}
\n

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

\n

Почему один успешный consumer ничего не доказывает

\n

Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки status.

\n

Отдельно проверяйте направление. Старый producer → новый consumer и новый producer → старый consumer — разные случаи. Добавление необязательного поля часто безопасно для старого tolerant reader, но не для старого strict reader. Удаление обязательного поля опасно в обратную сторону: новый consumer может ожидать его у сообщений, которые ещё пишет старый producer.

\n
Матрица изменений контракта и минимальная проверка
ИзменениеРискЧто проверяемРешение
Добавили необязательное полеStrict reader отвергает extra propertyПолитику неизвестных полей у каждого consumerМигрировать reader, не добавлять поле или выпускать версию
Удалили обязательное полеReader не может собрать объектВсе producer и накопленные сообщения старой версииСначала период совместной поддержки, затем удаление
Переименовали status в phaseЭто удаление и добавление, а не косметикаСсылки на старое имя и смысл каждого значенияДобавить новое поле с явной миграцией или новый контракт
Добавили значение enumКод consumer может иметь закрытый switchПоведение на неизвестном значении и запасную веткуРасширить reader до отправки нового значения
Изменили секунды на миллисекундыЧисло валидно, результат неверен в 1000 разЕдиницу измерения, диапазон и тест на границеНазвать единицу в поле или выпустить отдельную форму
\n

Воспроизводимый fail-closed gate

\n

Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения status и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.

\n
// contract-gate.mjs\nconst baseline = {\n  required: new Set(['orderId', 'status']),\n  types: { orderId: 'string', status: 'string' },\n  values: { status: new Set(['new', 'paid', 'cancelled']) },\n  properties: new Set(['orderId', 'status'])\n};\n\nconst candidate = {\n  required: new Set(['orderId', 'status']),\n  types: { orderId: 'string', status: 'string', priority: 'integer' },\n  values: { status: new Set(['new', 'paid', 'cancelled']) },\n  properties: new Set(['orderId', 'status', 'priority'])\n};\n\nfunction check({ producer, consumer, direction, reader }) {\n  if (!producer || !consumer || !direction) {\n    return 'STOP relation is not named';\n  }\n\n  for (const field of baseline.required) {\n    if (!candidate.required.has(field)) {\n      return `STOP required field removed: ${field}`;\n    }\n  }\n\n  for (const [field, type] of Object.entries(baseline.types)) {\n    if (candidate.types[field] !== type) {\n      return `STOP type changed: ${field}`;\n    }\n  }\n\n  const added = [...candidate.properties]\n    .filter((field) => !baseline.properties.has(field));\n  if (added.length && !reader.allowUnknownProperties) {\n    return `STOP unknown fields: ${added.join(', ')}`;\n  }\n\n  const oldStatuses = baseline.values.status;\n  const newStatuses = candidate.values.status;\n  const introduced = [...newStatuses].filter((value) => !oldStatuses.has(value));\n  if (introduced.some((value) => !reader.statusValues.has(value))) {\n    return `STOP unknown status value: ${introduced.join(', ')}`;\n  }\n\n  return 'PASS compatible for this named reader';\n}\n\nconst strictReader = {\n  allowUnknownProperties: false,\n  statusValues: new Set(['new', 'paid', 'cancelled'])\n};\nconst tolerantReader = {\n  allowUnknownProperties: true,\n  statusValues: new Set(['new', 'paid', 'cancelled'])\n};\n\nconsole.log(check({\n  producer: 'orders-api',\n  consumer: 'billing-worker',\n  direction: 'producer-writes-consumer-reads',\n  reader: strictReader\n}));\nconsole.log(check({\n  producer: 'orders-api',\n  consumer: 'analytics-loader',\n  direction: 'producer-writes-consumer-reads',\n  reader: tolerantReader\n}));\nconsole.log(check({ producer: 'orders-api', consumer: '', direction: '', reader: strictReader }));
\n

Сохраните код в contract-gate.mjs и выполните:

\n
node --check contract-gate.mjs\nnode contract-gate.mjs
\n

Ожидаемый вывод:

\n
STOP unknown fields: priority\nPASS compatible for this named reader\nSTOP relation is not named
\n

Это не готовый schema registry и не доказательство успешной доставки. Gate демонстрирует порядок: сначала связь, затем breaking change, затем политика reader. В реальном проекте типы и значения следует брать из версионируемой схемы, а список reader — из inventory интеграций, конфигурации маршрутов и consumer contract tests.

\n

Отдельно проверяем жизненный цикл status

\n

Структура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, paid → new может быть запрещённым переходом, а повторное paid — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.

\n

Не прячьте единицу времени в описании команды. Поля occurredAt и receivedAt отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.

\n

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

\n

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

\n
  1. Назовите отношение. Запишите contract family, producer, consumer, направление, транспорт и версию. Если consumer неизвестен, результат — остановка и поиск владельца.
  2. Сохраните baseline. Зафиксируйте обязательные поля, типы, enum, единицы измерения, ограничения и примеры до изменения.
  3. Опишите candidate. Разделите сохранённые, удалённые, переименованные и добавленные поля. Переименование рассматривайте как изменение формы и смысла, пока обратное не доказано.
  4. Проверьте reader policy. Узнайте, принимает ли consumer дополнительные поля и значения enum. Не выводите это из того, что один sample однажды прочитался.
  5. Проверьте отрицательные случаи. Удалите обязательное поле, добавьте неизвестное поле, передайте новое значение enum, смените единицу времени и отправьте сообщение в обратном направлении.
  6. Проверьте накопленные данные. Очередь, retry, архив и offline-клиенты могут доставить старую форму после релиза. Укажите срок совместного чтения и способ удаления старой версии.
  7. Сохраните адресный verdict. Запишите не только PASS или STOP, но и reader, входную версию, причину отказа и следующее действие. Общий итог можно вычислять только после результатов всех названных пар.
\n

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

\n

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

\n

Даже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что status относится к заказу, а не к платежу. Поле format также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.

\n

Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.

\n

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

\n

Изменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.

\n

Практическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение status. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает status: refunded для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.

\n

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

\n" }