Проблема начинается не в момент большой миграции, а после небольшой записи. Producer добавил поле timezone в profile/settings, reader увидел незнакомое состояние или решил, что отсутствующее поле равно null. Цена такой догадки — не красивый exception, а уже записанная версия профиля, которую старый код трактует иначе. Затем команда спорит о формате, хотя причина лежит в неописанном договоре между тем, кто пишет, и тем, кто читает.
Для августа 2021 года я бы не называл JSON контрактом. JSON даёт синтаксис объекта, но не владельца поля, не смысл строки, не срок поддержки старого reader и не порядок отката. В этой заметке договор строится вокруг одного нейтрального record profile.settings. Пример локальный: он работает только в памяти и не моделирует БД, файл, broker, репликацию или реальную миграцию.
Сначала записываем границу договора
У договора есть объект и владелец. Объект отвечает на вопрос, что именно меняется: в нашем случае настройки одного профиля, а не «пользовательские данные вообще». Владелец отвечает на другой вопрос: кто разрешает новый смысл поля, совместимость старого reader и момент, когда старый writer можно выключить. Если это не записано рядом со схемой, любое изменение выглядит локальным до первого consumer, который был собран раньше.
Минимум полезных частей: стабильный идентификатор, обязательные поля, необязательные поля, состояние absent, значение null, допустимые значения и человеческий смысл. Для emailDigest недостаточно написать «строка». В нашем договоре это режим частоты сводки: off, weekly или daily. Если завтра тем же словом начинают помечать маркетинговый сегмент, тип остаётся строкой, а смысл уже сломан.
const profileSettingsContract = {
owner: 'profile-settings',
identity: 'profile.id',
required: ['id', 'displayName', 'settings.emailDigest'],
optional: { timezone: 'absent | null | non-empty string' },
meaning: { emailDigest: 'summary cadence, not a marketing label' },
readers: ['v1 ignores optional unknown fields', 'v2 distinguishes absent and null'],
rollout: 'reader capability → additive writer → compatibility test',
};
| Часть | Кто задаёт правило | Что проверяем до записи | Что не обещает правило |
|---|---|---|---|
id | owner record | непустая строка и связь с одним профилем | что профиль существует в выбранном хранилище |
settings.emailDigest | owner значения и их смысла | значение входит в записанный набор | что строка сама раскрывает бизнес-смысл |
timezone | owner presence semantics | absent, null или непустая строка различены | что absent можно бездумно заменить на null |
| версия reader/writer | владелец rollout | compatibility matrix покрывает поддерживаемые пары | что любой старый consumer узнает новые поля |
Таблица намеренно не называет конкретный storage. Контракт живёт выше него: одна реализация может держать запись в документе, другая в строке или в сообщении. Смена места не отменяет правила чтения. И наоборот: выбранный сериализатор не делает смысл поля проверяемым. Поэтому owner должен хранить не только схему, но и список поддерживаемых направлений: старый writer → новый reader, новый writer → старый reader и новая пара.
Absent и null — два разных входа
Необязательное поле имеет минимум три состояния: ключ не пришёл, ключ пришёл с null, ключ пришёл со значением. Для старого writer отсутствие timezone означает только то, что он её не передал. Это не разрешение подставить «часовой пояс очищен». null в данной модели, наоборот, является явной командой очистки. Если приложение выбирает другую семантику, её нужно записать и проверить тем же способом.
const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
function timezoneState(record) {
if (!owns(record, 'timezone')) return 'absent';
if (record.timezone === null) return 'explicit-null';
if (typeof record.timezone === 'string' && record.timezone) return 'value';
throw new Error('timezone violates contract');
}
timezoneState({ id: 'profile-17' }); // absent
timezoneState({ id: 'profile-17', timezone: null }); // explicit-null
В JavaScript важно проверять наличие собственного ключа, а не правдивость значения. Условие if (record.timezone) смешает пустую строку, null и отсутствие поля; в нашем договоре все три случая требуют разных решений. Пустая строка не допускается вовсе, потому что она не обозначена как отдельное состояние. Такой запрет полезнее умного fallback: reader либо видит известный случай, либо останавливает интерпретацию и оставляет evidence для исправления.
Поддержка старых чтений и записей — это матрица, а не надежда
Перед rollout стоит назвать пары, которые действительно будут жить одновременно. В fixture writer v1 пишет только core-поля. Writer v2 добавляет timezone как optional. Reader v1 читает именованные core-поля и игнорирует неизвестный optional ключ. Reader v2 умеет прочитать старую запись и вернуть состояние absent, не изображая его очищенным значением. Именно эти четыре направления становятся тестом, а не устным обещанием.
const matrix = [
['writer v1', 'reader v1', 'accept'],
['writer v1', 'reader v2', 'accept: timezone absent'],
['writer v2 additive', 'reader v1', 'accept: unknown optional ignored'],
['writer v2', 'reader v2', 'accept: null is explicit clear'],
['writer v1 daily', 'narrowed reader', 'reject before rollout'],
];
Здесь есть важная граница: «игнорировать неизвестное» допустимо только для поля, которое не меняет старое обязательное поведение. Нельзя добавить новое поле, а затем сделать старый emailDigest зависимым от него без обновления reader. Тогда поле выглядит additive по форме, но становится semantic break. Так же нельзя сузить множество старых значений: если v1 писал daily, новый reader, который принимает только off и weekly, обязан быть отклонён migration test до релиза.
Безопасный rollout идёт от reader к writer
Порядок короткий. Сначала фиксируем старый договор и примеры старых записей. Затем добавляем reader, который понимает старый record и новое optional поле. Только после этого writer начинает посылать новое поле. Пока старый reader остаётся в поддерживаемой матрице, writer не должен превращать optional поле в обязательное и не должен менять смысл существующих значений. Удаление старого пути — отдельное изменение: для него нужны данные, что соответствующей пары больше нет, а не просто новая дата в схеме.
| Шаг | Изменение | Проверка | Стоп-сигнал |
|---|---|---|---|
| 1 | зафиксировать v1 и samples | v1 reader читает каждый sample | непонятен смысл или owner старого поля |
| 2 | добавить v2 reader | v1 record даёт timezone: absent | reader подставляет null без правила |
| 3 | добавить v2 writer | v1 reader читает core, v2 reader читает значение и clear | новое поле стало обязательным для старого пути |
| 4 | предложить удаление legacy | matrix не содержит поддерживаемую старую пару | есть record или consumer вне доказанной матрицы |
Rollback тоже должен знать границу. Если writer только добавил optional поле, можно остановить его выпуск и оставить reader совместимым с уже появившимися record. Если writer переиспользовал существующее значение с новым смыслом, простая отмена кода не возвращает старое значением прежний смысл. В таком случае сначала останавливают producer, сохраняют примеры и запускают отдельный контролируемый перевод данных. В нашем локальном примере такого перевода нет; он специально не выдаётся за готовую migration procedure.
Fixture превращает правило в проверяемую границу
Функция runStorageContractFixture() создаёт v1, additive v2 и v2 с явным null. Она читает их двумя consumer, переставляет ключи объекта, проверяет matrix и намеренно предлагает три плохих reader: с narrowed набором emailDigest, со сменой смысла и с обязательным timezone. У каждой гарантии есть assertion. Это не проверка сериализатора и не тест выбранной БД; это маленькое место, где изменение договора получает наблюдаемый ответ.
const fixture = runStorageContractFixture();
for (const [name, passed] of Object.entries(fixture.assertions)) {
if (!passed) throw new Error('failed invariant: ' + name);
}
// Fixture использует только Array, Map и объекты в памяти.
// Она не проверяет выбранное хранилище, репликацию или реальный rollout.
Отдельный плюс fixture — она защищает от красивых, но пустых слов. Нельзя сказать, что generic JSON «эволюционно совместим»: код принимает или отклоняет только правила, которые мы сами описали. Нельзя сказать, что объект упорядочен как contract: reader обращается к именам полей, а перестановка ключей даёт тот же результат. Нельзя сказать, что null равен отсутствию: обе ветви возвращают разные состояния. Когда правило меняется, рядом меняется assertion и matrix.
Маршрут: симптом → причина → проверка → действие
- Зафиксировать симптом: какой reader, какой record id, какая пара writer/reader и на каком поле разошлась интерпретация. Не начинать с массового переписывания данных.
- Назвать owner и ожидаемый смысл поля. Для
timezoneотдельно ответить, что означают absent,nullи строка. - Проверить текущую compatibility matrix на старых и новых samples. Особо проверить направление старый writer → новый reader.
- Если изменение additive, сначала выпустить reader, затем writer. Если поле становится required или меняет смысл, остановить rollout и оформить отдельный договор перехода.
- Добавить failing sample для narrowing, type break и semantic break. Без отрицательного примера тест проверяет только удачный путь.
- Удалять старую поддержку только после явной границы поддержки. До этого сохранять reader, который понимает уже записанные record.
Пределы модели и источники
RFC 8259 описывает JSON-объект как набор пар имя/значение и предупреждает о различиях реализации вокруг порядка членов. Поэтому порядок ключей в этой статье не является договором. JSON Schema draft 2019-09 полезен как язык structural assertions, но выбор валидатора и семантика приложения остаются с владельцем record. Apache Avro показывает другой, форматно-зависимый случай: у него есть writer и reader schema resolution. Нельзя переносить это правило на любой JSON лишь потому, что оба примера выглядят как данные.
В пакете не запускаются storage engine, реальные файлы, сети, schema registry, миграционный job, browser, CI или deployment. Нет SLA, production-метрик и заявления, что конкретный rollout уже происходил. Следующий практический шаг — взять один настоящий record без чувствительных значений, записать его owner и поддерживаемые направления чтения, затем перенести эти samples в локальный compatibility test. После этого можно обсуждать конкретное хранилище, а не наоборот.
Проверяемые источники
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — первичный стандарт JSON: объект состоит из пар имя/значение; порядок членов объекта не следует использовать как межсистемный договор
- JSON Schema draft 2019-09: Validation vocabulary — официально опубликованный Draft 2019-09 (17 сентября 2019, не IETF RFC) разделяет assertions для структуры и допускает тип
null; конкретный валидатор и режим проверки остаются выбором приложения - Apache Avro 1.10.2: Specification, schema resolution — официальная спецификация формата описывает resolution writer и reader schema; это пример форматно-зависимого правила, а не свойство произвольного JSON-объекта