{ "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Событие описывает факт: например, заказ перешёл в состояние paid. Доставка описывает попытку передать этот факт конкретному consumer. Одно событие может попасть к нему дважды. Другой consumer может прочитать тот же факт позже. Поэтому время обработки не заменяет идентичность входа. Поля id и source связывают повтор с исходным сообщением, а type и subject ограничивают его смысл.
Envelope отвечает за координаты события. Payload отвечает за данные конкретного типа. Результат consumer отвечает за уже применённую интерпретацию. Не смешивайте эти слои. Если producer положит в envelope всю предметную модель, любое изменение заказа станет изменением общего транспорта. Если consumer сохранит только итоговое число, расследование потеряет исходную схему и версию обработчика.
\nconst 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.
Сначала проверьте обязательные поля envelope. Неполный источник, пустой тип или чужой namespace нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, права, tenant, подпись и ограничения транспорта. Этот пример ограничен полями, которые нужны для разбора повторной доставки.
\nfunction 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Число schemaVersion имеет смысл только рядом с правилами reader. Пусть версия 1 содержит orderId и status, а версия 2 добавляет необязательное поле paymentReference. Consumer v1 может безопасно проигнорировать это поле, если он заранее объявил, что читает только устойчивую пару. Consumer v2 может прочитать его и вернуть null, если получил старую запись без этого поля.
Добавление поля не всегда совместимо. Если новое значение меняет смысл старого поля, меняется контракт. Переименование status в state не становится безопасным потому, что оба значения имеют тип string. То же относится к смене единицы измерения, enum, валюты и правил округления. Для такого изменения нужен новый contract или адаптер с отдельной проверкой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один эффект появился дважды | Повтор доставки не связан с receipt | Сравнить source:id и ключ consumer | Подавить повтор, сохранив evidence |
| Старый consumer падает на новом сообщении | Producer изменил обязательное поле или смысл | Сопоставить writer schema и accepted versions | Остановить вход или выпустить адаптер |
| Новый consumer видит пустое поле | В старой схеме поля не было | Проверить явное правило default | Вернуть ограниченный null или отказ |
| JSON валиден, результат неверен | Изменился смысл значения, но версия осталась прежней | Сверить семантику stable fields с владельцем домена | Поднять версию и запретить угадывание |
| Нельзя понять, какой код создал запись | Результат не хранит consumer contract | Найти inputSchemaVersion и resultVersion | Добавить их в receipt до replay |
Минимальное описание consumer содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, orders-projection@1 принимает схемы 1 и 2, читает только orderId и status, а для версии 3 возвращает contract-update-required. Он не пытается найти похожее поле и не превращает незнакомое значение в default.
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 внешней стороны или ручной маршрут для неопределённого результата.
Controlled replay повторяет тот же вход, поэтому сохраняет source, id, type, subject и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Это опасная подмена: consumer уже не отличит восстановление от новой команды.
Два разных consumer могут законно создать две проекции по одному событию. Но новый результат должен иметь другой явно объявленный consumerId и resultVersion. Это не разрешение повторить платеж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.
id, source, type, subject, contractVersion и время факта.consumerId, resultVersion и ключ ledger.Этот материал не обещает 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Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с inputSchemaVersion и resultVersion. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же source:id не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.