{ "index": 66, "slug": "editorial-2026-03-practice-data-contracts", "title": "Изменение схемы без устных договорённостей: как проверить контракт данных", "excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.", "contentHtml": "
Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки — не только ошибка парсинга: в очереди остаются сообщения, повторная доставка увеличивает нагрузку, а поиск владельца контракта съедает время.
\nЧтобы не обсуждать совместимость на уровне догадок, нужно проверить одну конкретную пару: какая форма была исходной, какая стала новой, кто пишет данные и какой reader их читает. В этой статье разберём маршрут для JSON-подобного объекта. Он не заменяет интеграционные тесты и не объявляет изменение безопасным для неизвестных клиентов.
\nВерсия v1.1 сама по себе ничего не гарантирует. Старый reader может игнорировать незнакомые поля, а может использовать строгую проверку и отклонять их. Один и тот же candidate поэтому совместим с одним consumer и несовместим с другим.
Дальше под backward compatibility будем понимать одно проверяемое направление: старый reader получает запись, созданную новой схемой. Это определение относится только к выбранной паре. Оно не доказывает обратное направление, совместимость SDK, сохранённых сообщений или других потребителей.
\nBaseline — форма, которую уже принимает named consumer. Candidate — полная форма после изменения. Manifest — явный список добавленных, удалённых и изменённых полей. Наконец, поведение consumer — правила его парсера: допускает ли он дополнительные ключи и как обрабатывает неизвестные значения.
Нельзя подменять baseline коротким описанием вроде «добавили priority». Если одновременно изменились тип amount, enum state или единицы измерения, короткая заметка скроет breaking change. Полный candidate и diff должны быть доступны тому, кто будет читать запись после выпуска.
Ниже — самостоятельный пример на Node.js без сторонних пакетов. Сохраните код в файл contract-gate.mjs и запустите командой node contract-gate.mjs. Внутренняя модель намеренно мала: она проверяет имена, типы, обязательность, manifest и способность consumer принимать дополнительные ключи.
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.
Пример можно проверить на чистой машине с Node.js 18 или новее: node --version покажет установленную версию, а node contract-gate.mjs выведет три объекта в консоль. Скрипт не обращается к сети, registry или вашему сервису, поэтому его положительный результат относится только к указанным данным.
| Результат | Что установлено | Следующий шаг | Граница вывода |
|---|---|---|---|
compatible-for-named-reader | Схемы и manifest совпали, reader допускает addition | Запустить contract/integration tests и проверить rollout | Неизвестные consumer и семантика значений не проверены |
stop-required-field-removed | В candidate исчезло обязательное поле baseline | Сохранить поле на период миграции или версионировать контракт | Переименование не исправляется номером версии |
stop-consumer-rejects-additional-fields | Reader не принимает добавленные ключи | Изменить reader, изолировать новый контракт или дождаться миграции | Свойство optional в схеме не меняет parser policy |
stop-manifest-mismatch | Фактический diff шире заявленного | Исправить candidate или manifest и повторить проверку | Gate не выясняет смысл незаявленного поля |
stop-field-definition-changed | Изменился тип или признак обязательности | Сделать отдельный migration plan и тесты значений | Даже тот же JSON-тип может скрывать смену единиц |
JSON Schema отвечает на вопрос о валидности экземпляра относительно набора ограничений. В спецификации 2020-12 есть структурные ключевые слова type, required и additionalProperties. Они помогают описать форму объекта и правила дополнительных ключей, но не знают, какой сервис владеет полем и какой reader будет обрабатывать запись.
Это видно и по JSON Type Definition (JTD). RFC 8927 разделяет properties и optionalProperties, а режим дополнительных свойств задаётся отдельно. Такой словарь дисциплинирует схему, но не выбирает срок поддержки старой формы и не проверяет ваш parser.
В Apache Avro терминология writer schema и reader schema встроена в механизм разрешения схем. Спецификация описывает, как reader сопоставляет поля, что происходит с отсутствующим полем и когда возникает ошибка. Для обычного JSON API правила Avro автоматически не применяются, но сам способ постановки вопроса полезен: всегда указывайте обе стороны чтения и записи.
\nДобавление необязательного поля часто проще, чем удаление обязательного. Но это не универсальное правило. Строгий parser, подписанный payload, whitelist ключей или downstream-система с фиксированным форматом могут отклонить additive change.
\nОпаснее всего изменение смысла без изменения типа. amount мог быть суммой в рублях, а стал суммой в копейках. Строковый timestamp мог перейти из локального времени в UTC. Enum state=ready мог означать «готов к отправке», а после изменения — «готов к оплате». Structural diff такие изменения не докажет. В manifest нужны единицы, timezone, допустимые значения и ссылка на владельца доменного смысла.
Если контракт использует JSON Schema, структурную валидацию можно выполнять отдельно, например через выбранный валидатор в CI. Его настройки должны быть зафиксированы: разные библиотеки могут по-разному трактовать форматные аннотации. Проверка format: date-time не заменяет проверку бизнес-часового пояса и срока действия события.
added, removed и changed. Для изменения смысла добавьте текстовое правило и владельца.Описанный gate проверяет только одну форму объекта и одного named consumer. Он не обнаружит неизвестных внешних клиентов, старые записи в очереди, кэшированные ответы, сгенерированные SDK, схемы в базе или трансформации промежуточного сервиса. Для этого нужен инвентарь потребителей и тесты на реальные границы системы.
\nПоложительный результат не является самостоятельным разрешением на deploy. Нужны проверка авторизации, размер payload, подпись, порядок событий, повторная доставка, таймауты и наблюдаемость. Эти свойства не следуют из JSON Schema и не выводятся из номера версии.
\nНаконец, не называйте изменение backwards-compatible, если вы проверили только новый reader на старой записи. Это другое направление. Если продукт требует оба направления, проведите две отдельные проверки и запишите их результаты в manifest.
\nПеред review другая команда должна без устного пояснения ответить на пять вопросов: какая форма является baseline, что изменилось в candidate, совпадает ли фактический diff с manifest, кто читает новую запись и какое правило дополнительных ключей действует у reader. Если хотя бы один ответ неизвестен, результат проверки — остановка и уточнение контракта.
\nТакой порядок не делает изменение автоматически безопасным. Он делает риск видимым: структурную ошибку можно поймать до выпуска, несовместимый parser — проверить на named consumer, а смену бизнес-смысла — вынести в отдельное решение. Именно эта граница превращает сообщение «мы добавили одно поле» в проверяемое инженерное изменение.
\ntype, required, enum и связанные ключевые слова. Статья использует её только для описания формы JSON, а не как универсальный compatibility policy.properties, optionalProperties и additionalProperties в JTD. Это отдельный формат, его правила нельзя молча переносить на JSON Schema.