{ "index": 66, "slug": "editorial-2026-03-practice-data-contracts", "title": "Изменение схемы без устных договорённостей: как проверить контракт данных", "excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.", "contentHtml": "
Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки складывается из простоя, ручного восстановления данных и времени на поиск настоящего контракта.
\nПроблема начинается не с синтаксиса схемы. Она начинается с неявного обещания: «новое поле необязательное, значит всё совместимо». Это утверждение неполно. Нужно назвать исходную форму, новую форму, направление чтения, producer и конкретного consumer. Только после этого можно решить, additive change это или несовместимое изменение.
\nНомер v1.1 связывает две точки во времени, но не отвечает на главный вопрос. Может ли reader, рассчитанный на baseline, принять candidate? Ответ зависит от обязательных полей, типов, дополнительных ключей и правил самого reader. Один consumer игнорирует незнакомые поля. Другой отвергает их. Одинаковый JSON для них имеет разный результат.
Контракт данных — это не только схема. Это схема вместе с владельцем записи, ожидаемым reader, направлением совместимости и правилом изменения. Для практической проверки достаточно начать с одной пары: producer создаёт candidate, named consumer читает его как продолжение baseline. Остальные потребители требуют отдельных проверок.
\nСначала зафиксируйте baseline — форму, которую уже читает потребитель. Затем опишите candidate — форму после изменения. В manifest перечислите добавленные, удалённые и изменённые по типу поля. Направление backward в этом материале означает: старый reader получает новую запись. Это не означает, что новый reader обязательно прочитает старую запись.
Проверка должна идти в том же порядке. Сначала она убеждается, что обязательная поверхность baseline не исчезла. Затем проверяет, что каждое новое поле названо в manifest. После этого она спрашивает capability конкретного consumer: принимает ли он дополнительные ключи. Если входные данные не называют направление или consumer, проверка останавливается. Пустое сравнение нельзя считать зелёным результатом.
\nНиже — учебная функция. Она не подключается к registry, не читает реальные схемы и не доказывает совместимость сервиса. Её задача — сделать порядок решений видимым.
\nconst baseline = {\n id: { type: 'string', required: true },\n state: { type: 'string', required: true },\n note: { type: 'string', required: false },\n};\n\nconst candidate = {\n ...baseline,\n priority: { type: 'number', required: false },\n};\n\nconst change = {\n direction: 'backward',\n producer: 'work-item-api',\n consumer: 'billing-worker-v1',\n added: ['priority'],\n removed: [],\n changed: [],\n};\n\nfunction review({ baseline, candidate, change, acceptsAdditional }) {\n const requiredLost = Object.entries(baseline)\n .filter(([name, field]) => field.required && !candidate[name])\n .map(([name]) => name);\n\n if (!change.direction || !change.consumer) {\n return { status: 'stop-implicit-comparison' };\n }\n if (requiredLost.length > 0) {\n return { status: 'stop-backward-incompatible-schema', requiredLost };\n }\n\n const actualAdded = Object.keys(candidate)\n .filter((name) => !baseline[name]);\n const undocumented = actualAdded\n .filter((name) => !change.added.includes(name));\n\n if (undocumented.length > 0) {\n return { status: 'stop-undocumented-schema-field', undocumented };\n }\n if (actualAdded.length > 0 && !acceptsAdditional) {\n return { status: 'stop-incompatible-consumer' };\n }\n return { status: 'synthetic-compatibility-review-hand-off' };\n}\n\nconsole.log(review({\n baseline,\n candidate,\n change,\n acceptsAdditional: true,\n}));\n// { status: 'synthetic-compatibility-review-hand-off' }\nВ примере priority не удаляет id и state, поэтому структурная проверка проходит. Поле также записано в manifest. Tolerant consumer принимает дополнительные ключи, и функция возвращает ограниченный положительный статус. Этот статус означает только одно: фиксированная учебная пара прошла перечисленные правила. Он не означает deploy, миграцию базы или успешную обработку реального сообщения.
Теперь измените candidate: замените state на phase. Функция вернёт stop-backward-incompatible-schema. Имена похожи, но старый consumer всё ещё ищет обязательное поле state. Не пытайтесь исправить этот результат добавлением номера версии. Здесь нужен отдельный план миграции или сохранение старого поля на период перехода.
Третий случай — strict consumer. Оставьте additive candidate, но передайте acceptsAdditional: false. Результат станет stop-incompatible-consumer. Поле может быть корректным для одного reader и запрещённым для другого. Поэтому слово «optional» должно описывать не только schema declaration, но и поведение потребителя.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый reader падает на новом ключе | Consumer запрещает дополнительные поля | Проверить его parser policy и тест на candidate | Оставить поле вне старой формы, изменить reader или ввести отдельный контракт |
| После rename пропало значение | Удалено обязательное поле baseline | Сравнить required-поля baseline и candidate | Вернуть поле на переходный период или спроектировать миграцию |
| В review нет единого вердикта | Не задано направление или named consumer | Проверить manifest: direction, producer, consumer | Остановить изменение и сначала определить пару |
| Новый ключ появился без обсуждения | Diff шире заявленного manifest | Сравнить фактические ключи candidate со списком added | Назвать поле и его смысл либо удалить его из candidate |
| Тип остался строкой, но смысл изменился | Семантический breaking change не виден в structural diff | Сверить единицы, timezone, enum и документацию consumer | Дать новое имя или подготовить явную миграцию значения |
JSON Schema описывает структуру экземпляра: свойства, типы и дополнительные свойства. Это полезная граница, но schema validation не знает, какой сервис владеет полем, кто читает объект и можно ли менять смысл значения без новой версии. Две схемы могут быть валидными по отдельности и всё равно не образовывать безопасную пару.
\nТа же граница видна в JSON Type Definition. В RFC 8927 required properties и optionalProperties разделены явно. Режим дополнительных свойств тоже задаётся отдельно. Это хороший словарь для разговора о форме объекта. Но RFC не выбирает migration policy вашей команды и не сообщает, выдержит ли конкретный consumer изменение.
\nВ форматах с writer и reader schemas направление становится ещё заметнее. Apache Avro описывает schema resolution между схемой записи и схемой чтения. Для JSON-сервисов конкретные правила будут другими, но принцип переносим: нельзя обсуждать compatibility без указания стороны, которая пишет, и стороны, которая читает.
\nadded, removed и changed. Любое поле вне списка считается неоформленным.Учебный gate не видит неизвестных внешних клиентов. Он не проверяет кеши, очереди, сохранённые payload, SDK, базы и семантику бизнес-значений автоматически. Он также не определяет срок поддержки старой формы. Для этих вопросов нужны реальные инвентари потребителей, contract tests и план удаления.
\nДобавление необязательного поля часто безопаснее удаления обязательного, но это не универсальное правило. Строгий parser, подпись payload или downstream-система с закрытым набором ключей превращают additive change в остановку. Не называйте поле безопасным только потому, что оно не помечено как required.
\nОтдельный риск — изменение смысла без изменения типа. Строка amount может перейти с рублей на копейки. Timestamp может сменить timezone. Enum может получить другой смысл при том же наборе строк. Structural diff этого не докажет. Нужны доменное описание, тесты значений и проверка consumer.
Изменение готово к следующему review, если другая инженерная команда без устного пояснения может открыть одну карточку и ответить на пять вопросов: какая форма была baseline, какая стала candidate, что изменилось по manifest, кто читает результат и в каком направлении выполнялась проверка. Для additive change дополнительно нужен положительный тест tolerant consumer и отрицательный тест strict consumer. Для breaking change нужен отдельный migration или versioning decision.
\nЕсли хотя бы один ответ неизвестен, итогом должен быть stop, а не зелёный комментарий. Такая остановка дешевле аварийного отката: она превращает неясное обещание в конкретный вопрос, который можно проверить.
\nproperties и additionalProperties.properties, optionalProperties и режима дополнительных свойств.