{ "index": 237, "slug": "editorial-2021-06-practice-event-driven", "title": "Событийный контракт: как пережить повтор, новую схему и неизвестный consumer", "excerpt": "Очередь передаёт данные, но не объясняет их смысл и не защищает эффект от повтора. Разбираем envelope, версию схемы, consumer contract и проверку, которая останавливает опасный вход до изменения состояния.", "contentHtml": "

Симптом появляется после подключения второго consumer. Заказ уже сменил статус, но обработчик не может ответить на четыре вопроса: кто создал событие, к какому объекту оно относится, какую схему он получил и записывал ли этот вход результат раньше. Иногда сообщение приходит дважды после повтора отправки. Иногда producer добавляет поле, а старый consumer принимает JSON и неверно трактует новое значение. Цена ошибки — не только красный лог. Система может дважды отправить письмо, повторно изменить внешний объект или записать правдоподобную, но неверную проекцию.

\n

Тезис простой: событийный контракт должен разделять факт, доставку и эффект. Producer фиксирует устойчивый envelope и версию payload. Consumer объявляет принимаемые версии и ключ идемпотентности. Неизвестная схема меняет маршрут на отказ или ручную обработку. Повтор той же доставки не получает новый event id. Такой контракт не даёт обещания exactly-once. Он оставляет доказательство, которое позволяет безопасно принять решение.

\n

Событие не равно доставке

\n

Событие описывает факт: например, заказ перешёл в состояние paid. Доставка описывает попытку передать этот факт конкретному consumer. Одно событие может попасть к нему дважды. Другой consumer может прочитать тот же факт позже. Поэтому время обработки не заменяет идентичность входа. Поля id и source связывают повтор с исходным сообщением, а type и subject ограничивают его смысл.

\n

Envelope отвечает за координаты события. Payload отвечает за данные конкретного типа. Результат consumer отвечает за уже применённую интерпретацию. Не смешивайте эти слои. Если producer положит в envelope всю предметную модель, любое изменение заказа станет изменением общего транспорта. Если consumer сохранит только итоговое число, расследование потеряет исходную схему и версию обработчика.

\n
const event = {\n  contractVersion: 1,\n  id: 'evt-104',\n  source: 'training://orders/checkout',\n  type: 'order.status.changed',\n  subject: 'order-104',\n  occurredAt: '2026-07-31T12:00:00Z',\n  data: {\n    schemaVersion: 1,\n    orderId: 'order-104',\n    status: 'paid'\n  }\n};\n\nconst resultKey = `${consumerId}:${event.source}:${event.id}`;
\n

Идентификаторы и значения в примере учебные. Код не подключается к брокеру, не моделирует базу и не доказывает гарантию доставки. Он показывает границу контракта: один логический вход сохраняет один id, а результат связывает его с конкретным consumer.

\n

Envelope проверяется до payload

\n

Сначала проверьте обязательные поля envelope. Неполный источник, пустой тип или чужой namespace нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, права, tenant, подпись и ограничения транспорта. Этот пример ограничен полями, которые нужны для разбора повторной доставки.

\n
function validateEnvelope(input) {\n  const required = ['id', 'source', 'type', 'subject', 'contractVersion'];\n  for (const field of required) {\n    if (typeof input[field] !== 'string' || input[field] === '') {\n      return { ok: false, reason: `missing-${field}` };\n    }\n  }\n\n  if (!input.source.startsWith('training://orders/')) {\n    return { ok: false, reason: 'source-outside-orders-boundary' };\n  }\n\n  if (!Number.isInteger(input.data?.schemaVersion)) {\n    return { ok: false, reason: 'missing-schema-version' };\n  }\n\n  return { ok: true };\n}
\n

Порядок важен. Сначала проверка границы, затем выбор consumer contract, затем чтение payload, затем запись результата. Иначе обработчик может частично изменить состояние и только потом обнаружить, что не знает владельца события или его версию.

\n
\"Producer
Схема разделяет факт, передачу и результат. Брокер на ней обозначает границу транспорта, а не конкретную гарантию продукта.
\n

Версия схемы задаёт право на чтение

\n

Число schemaVersion имеет смысл только рядом с правилами reader. Пусть версия 1 содержит orderId и status, а версия 2 добавляет необязательное поле paymentReference. Consumer v1 может безопасно проигнорировать это поле, если он заранее объявил, что читает только устойчивую пару. Consumer v2 может прочитать его и вернуть null, если получил старую запись без этого поля.

\n

Добавление поля не всегда совместимо. Если новое значение меняет смысл старого поля, меняется контракт. Переименование status в state не становится безопасным потому, что оба значения имеют тип string. То же относится к смене единицы измерения, enum, валюты и правил округления. Для такого изменения нужен новый contract или адаптер с отдельной проверкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один эффект появился дваждыПовтор доставки не связан с receiptСравнить source:id и ключ consumerПодавить повтор, сохранив evidence
Старый consumer падает на новом сообщенииProducer изменил обязательное поле или смыслСопоставить writer schema и accepted versionsОстановить вход или выпустить адаптер
Новый consumer видит пустое полеВ старой схеме поля не былоПроверить явное правило defaultВернуть ограниченный null или отказ
JSON валиден, результат неверенИзменился смысл значения, но версия осталась прежнейСверить семантику stable fields с владельцем доменаПоднять версию и запретить угадывание
Нельзя понять, какой код создал записьРезультат не хранит consumer contractНайти inputSchemaVersion и resultVersionДобавить их в receipt до replay
\n

Consumer обязан объявить контракт

\n

Минимальное описание consumer содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, orders-projection@1 принимает схемы 1 и 2, читает только orderId и status, а для версии 3 возвращает contract-update-required. Он не пытается найти похожее поле и не превращает незнакомое значение в default.

\n
function apply(event, consumer, ledger) {\n  const envelope = validateEnvelope(event);\n  if (!envelope.ok) return { state: 'rejected', reason: envelope.reason };\n\n  if (!consumer.acceptedSchemaVersions.includes(event.data.schemaVersion)) {\n    return { state: 'contract-update-required', effect: false };\n  }\n\n  const key = `${consumer.id}:${event.source}:${event.id}`;\n  if (ledger.has(key)) {\n    return { state: 'duplicate-or-replay-suppressed', key, effect: false };\n  }\n\n  const projection = consumer.project(event.data);\n  const result = {\n    key,\n    inputSchemaVersion: event.data.schemaVersion,\n    resultVersion: consumer.resultVersion,\n    projection\n  };\n  ledger.set(key, result);\n  return { state: 'effect-recorded', result };\n}
\n

В этом учебном коде Map заменяет ledger только для демонстрации последовательности. В рабочей системе запись receipt и внешний эффект могут пересекать границу транзакции. Тогда одной проверки в памяти недостаточно: нужен конкретный storage contract, идемпотентный API внешней стороны или ручной маршрут для неопределённого результата.

\n

Повтор не должен менять идентичность

\n

Controlled replay повторяет тот же вход, поэтому сохраняет source, id, type, subject и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Это опасная подмена: consumer уже не отличит восстановление от новой команды.

\n

Два разных consumer могут законно создать две проекции по одному событию. Но новый результат должен иметь другой явно объявленный consumerId и resultVersion. Это не разрешение повторить платеж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.

\n

Порядок внедрения

\n
  1. Назовите один наблюдаемый симптом и цену ошибки. Не начинайте с выбора брокера.
  2. Опишите факт, который producer передаёт, и отделите его от попытки доставки.
  3. Зафиксируйте envelope: id, source, type, subject, contractVersion и время факта.
  4. Опишите payload schema и stable fields. Для каждого поля укажите тип и смысл.
  5. Запишите accepted schema versions, projection, consumerId, resultVersion и ключ ledger.
  6. Проверьте старую схему с новым consumer и новую схему со старым consumer. Отдельно проверьте изменение смысла.
  7. Прогоните initial delivery, duplicate и controlled replay. Убедитесь, что replay не получает новый event id.
  8. Для неизвестной версии верните отказ без эффекта и сохраните причину вместе с идентификатором входа.
  9. Только после этого выберите broker, serializer, storage и правила retry. Запишите их реальные ограничения отдельно от контракта.
\n

Ограничения и отрицательный путь

\n

Этот материал не обещает ordering, exactly-once, отсутствие duplicate, бесконечный retention или атомарность между брокером и базой. Он не заменяет outbox, schema registry, transaction, authorization и integration test. CloudEvents помогает стандартизировать контекст события, но не знает, какой бизнес-эффект допустим. Kafka может повторить отправку при неопределённом результате; настройки producer не отменяют идемпотентность приложения и внешней системы. Avro описывает совместное чтение writer и reader schema, но не решает смысл доменных значений.

\n

Отрицательный путь обязателен. Если consumer не знает schema version, source не входит в его границу или значение изменило смысл, он не должен «попробовать как раньше». Сохраните вход и причину отказа без эффекта. Если внешний эффект мог завершиться, остановите автоматический replay до проверки receipt. Явный отказ дешевле тихой записи, которую потом нельзя доказать.

\n

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

\n

Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с inputSchemaVersion и resultVersion. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же source:id не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.

\n

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

\n" }