{ "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 проходит, а решение всё равно ломает процесс.
Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары producer → consumer, версии, направления передачи и набора правил чтения.
Контракт данных — это не перечень ключей. Он отвечает как минимум на пять вопросов: какие поля обязательны, какие типы и единицы измерения допустимы, какие значения имеют деловой смысл, разрешены ли дополнительные поля и как долго сообщение остаётся читаемым. Если в карточке изменения написано только «добавили status», consumer не получил достаточного описания.
В учебном кейсе producer отправляет заказ. В первой версии поле status означает жизненный цикл заказа: new, paid, cancelled. Другой сервис использует такое же имя для результата платежной операции: authorized, captured, refunded. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.
Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.
\nJSON Schema хорошо описывает первый слой. В спецификации есть type, required, enum и additionalProperties. Но сама структурная проверка не знает, означает ли paid захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.
{\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.
Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки status.
Отдельно проверяйте направление. Старый 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 раз | Единицу измерения, диапазон и тест на границе | Назвать единицу в поле или выпустить отдельную форму |
Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения status и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.
// 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 и выполните:
node --check contract-gate.mjs\nnode contract-gate.mjs\nОжидаемый вывод:
\nSTOP 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Структура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, paid → new может быть запрещённым переходом, а повторное paid — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.
Не прячьте единицу времени в описании команды. Поля occurredAt и receivedAt отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.
Для бинарных схем риск может выглядеть иначе. В Protocol Buffers номер поля участвует в wire format и не должен меняться или переиспользоваться; удалённые номера и имена рекомендуется резервировать, чтобы позднее не создать конфликт. В Avro reader и writer schema сопоставляются по правилам schema resolution. Это полезные модели, но их правила нельзя механически переносить на произвольный JSON API.
\nОписанный gate проверяет ограниченную структурную и перечислимую часть контракта. Он не обнаруживает скрытых consumers, не анализирует код всех клиентов, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку или корректность бизнес-переходов. Для этого нужны отдельные тесты, наблюдаемость и владелец интеграции.
\nДаже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что status относится к заказу, а не к платежу. Поле format также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.
Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.
\nИзменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.
\nПрактическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение status. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает status: refunded для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.
type, required, enum, объектные ограничения и различие format-annotation/format-assertion.