FinCodeCow · Глава III

Анатомия идеального события

Контракты, схемы и эволюция данных: как проектировать события, которые переживут годы изменений

Отец теории информации Клод Шеннон сформулировал главную проблему любой коммуникации: «Фундаментальная проблема обмена информацией состоит в том, чтобы в одной точке точно или приблизительно воспроизвести сообщение, выбранное в другой точке». В мире событийно-управляемых микросервисов эту проблему решает контракт на передачу данных.

Событие — это не просто пакет байтов, летящий по сети. Это юридический контракт между производителем и потребителем. Но в отличие от REST API, где версию видно в URL, у события нет «визитной карточки» — формат меняют, не задумываясь о том, что где-то сидит потребитель, который распарсит старое поле и упадёт в продакшене.

Эта глава — третья в цикле о событийно-управляемой архитектуре. Первая отвечала на вопрос «почему», вторая — «как устроено под капотом». Теперь мы разберём, как создавать события, которые переживут смену бизнес-требований, независимое развёртывание команд и эволюцию схем на годы вперёд.

• • •

IКонверт и груз: анатомия события через призму метаданных

Прежде чем говорить о payload, нужно разделить событие на два слоя: конверт (envelope) и груз (payload). Многие команды проектируют только payload и забывают, что без стандартизованного конверта распределённая трассировка превращается в ад.

Конверт — это метаданные, которые присутствуют в каждом событии независимо от его бизнес-типа:

Поле Назначение
event_idУникальный идентификатор (UUID v4/v7) для дедупликации и идемпотентности
event_typeИмя события (например, order.placed) для маршрутизации и диспетчеризации
event_versionВерсия схемы для выбора логики обработки
occurred_atВремя свершения бизнес-факта (не время публикации в брокер!)
correlation_idСвязывает все события одной распределённой транзакции
causation_idУказывает на конкретное событие или команду, которая причиной данного события
sourceИмя сервиса-производителя для аудита и отладки
schema_refID схемы в Schema Registry (для бинарных форматов)

Correlation ID связывает все события, порождённые одним пользовательским действием: пользователь нажал «Оформить заказ» → OrderPlaced, PaymentProcessed, InventoryReserved, ShippingScheduled — все несут один correlation_id. Без него поиск причины «почему заказ не ушёл» в распределённой системе — это часы ручного сопоставления логов.

Causation ID — тоньше: он указывает на конкретное событие, которое причиной текущего. PaymentProcessed был причиной OrderConfirmed. Это позволяет восстановить полную цепочку причинно-следственных связей — аналог стектрейса для асинхронного мира.

{
  "event_id": "evt_0192f8a3-...",
  "event_type": "order.placed",
  "event_version": 2,
  "occurred_at": "2026-09-29T12:00:00Z",
  "correlation_id": "req-abc-123",
  "causation_id": "evt_0192f8a1-...",
  "source": "checkout-service",
  "schema_ref": 42,
  "payload": {
    "order_id": "ord_92814",
    "customer_id": "cus_42",
    "total_amount": 149.90,
    "currency": "RUB"
  }
}
Ключевой принцип

Конверт стабилен и не зависит от бизнес-типа. Payload меняется по схеме. Разделение этих слоёв позволяет менять бизнес-логику, не переписывая инфраструктуру трассировки и дедупликации.

• • •

IIТри формата, три философии: Avro, Protobuf, JSON Schema

Любой контракт в EDM опирается на два столпа: определение данных (схема) — какие поля, типы и структуры передаются, и триггерная логика — почему и при каких бизнес-условиях событие было сгенерировано. Если вы не используете явную схему (например, просто отправляете JSON-строку), вы полагаетесь на «племенные знания» и устные договорённости.

Форматы вроде Apache Avro, Protobuf и JSON Schema стали стандартом де-факто, потому что решают главную боль микросервисов — независимое развёртывание. Но выбор формата — это не просто вопрос размера сообщения.

Критерий Avro Protobuf JSON Schema
Сильная сторона Гибкая эволюция схем Производительность и строгая типизация Читаемость, низкий порог входа
Размер сообщения Компактный (на 30–40% меньше JSON) Самый компактный (на 20–30% меньше Avro) Крупнее бинарных в 3–5 раз
Ключ к совместимости Сопоставление writer/reader схем через registry Постоянные номера полей (tag) Внешняя валидация
Кодогенерация Не требуется (динамическая типизация) Требуется (.proto → классы) Не требуется
Где чаще используют Kafka, Data Lakes, ETL gRPC, высоконагруженные микросервисы REST API, конфигурация, отладка

Правило выбора: Avro — когда эволюция схем критична и есть Schema Registry. Protobuf — когда нужна максимальная производительность и строгая типизация на уровне компилятора. JSON Schema — когда читаемость важнее компактности, а объёмы невелики.

Реальные цифры на 1 млн событий в сутки, хранение 365 дней, replication factor 3: JSON — около 50 ГБ/год, Avro — около 12 ГБ, Protobuf — около 10 ГБ.

Важный нюанс: Avro поддерживает aliases — механизм переименования поля без breaking change. Например:

{
  "name": "customer_email",
  "type": "string",
  "aliases": ["email", "customerEmail"]
}

Старые потребители, читающие поле email, продолжат работать после переименования в customer_email. Это редкая и ценная фича Avro, которая недоступна в Protobuf без явных reserved-полей.

• • •

IIIЭволюция схем: три уровня совместимости и что в них разрешено

Поддержка правил эволюции позволяет производителям и потребителям обновляться в разное время. Но «совместимость» — это не единое понятие. Существуют три базовых режима и три транзитивных расширения.

Backward (обратная)

Новый потребитель читает данные, записанные по старой схеме. Разрешено: добавление опциональных полей с default, удаление полей, смена имени через aliases. Запрещено: добавление обязательных полей без default, смена типа.

Forward (прямая)

Старый потребитель читает данные по новой схеме. Разрешено: добавление полей (старый потребитель их игнорирует), удаление опциональных полей. Запрещено: удаление обязательных полей, смена типа.

Full (полная)

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

Transitive

BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE проверяют совместимость со всеми версиями в истории, а не только с предыдущей. Критично для event sourcing и долгого хранения.

Изменение Backward Forward Full
Добавить опциональное поле с defaultДаДаДа
Добавить обязательное полеНетНетНет
Удалить опциональное полеДаДаДа
Удалить обязательное полеНетДаНет
Переименовать полеТолько через aliasesТолько через aliasesНет
Изменить тип поляОбычно нетОбычно нетНет
Какой режим выбрать

BACKWARD — если обновляете потребителей первыми. FORWARD — если обновляете производителей первыми. FULL — если команды деплоятся независимо и в произвольном порядке. TRANSITIVE — если события хранятся долго и должны читаться годами вперёд.

• • •

IVSchema Registry и Event Catalog: инфраструктура контрактов

Правила совместимости без инструмента enforcement — это просто надежда, что «кто-то не забудет». Schema Registry превращает правила в обязательный механизм.

Как это работает: продюсер перед публикацией регистрирует схему в registry. Registry проверяет совместимость новой версии с предыдущими (или со всеми — в transitive-режиме). Если схема несовместима, registry отклоняет публикацию — ошибка возникает на этапе деплоя, а не в продакшене у потребителя. В сообщении передаётся только ID схемы (4–5 байт), а не вся схема.

Основные реализации Schema Registry:

Решение Тип Когда выбирать
Confluent Schema Registry Коммерческий + Community Экосистема Confluent, глубокая интеграция с Kafka
Apicurio Registry Open-source (Red Hat) Независимость от вендора, поддержка Avro / Protobuf / JSON Schema
AWS Glue Schema Registry Managed (AWS) Serverless-стек, нативные AWS-сервисы
Karapace Open-source (Aiven) Drop-in замена Confluent Schema Registry

Event Catalog — каталог бизнес-смысла

Schema Registry — это техническая инфраструктура. Но для организаций с десятками команд нужен Event Catalog — каталог, где описаны не только схемы, но и бизнес-смысл событий, владельцы, потребители, примеры.

Schema Registry

Хранит схемы, проверяет совместимость, выдаёт версии. Отвечает на вопрос «как выглядит событие».

Event Catalog

Описывает бизнес-смысл, владельца, потребителей, примеры, SLA. Отвечает на вопрос «что означает событие и кто его использует».

Примеры инструментов: DataHub, Backstage (с плагинами для событий), Confluent Stream Catalog. Оба слоя — registry и catalog — дополняют друг друга: без registry контракты не защищены, без catalog они непонятны.

• • •

VЛовушка поля type: анатомия плохого дизайна

Одна из самых частых ошибок — попытка создать «универсальное» событие, добавив поле type, чтобы охватить сразу несколько бизнес-сценариев. Это нарушает принцип единственной ответственности и превращает схему в свалку nullable-полей.

Антипаттерн: универсальное событие

struct ProductEngagement {
  productId: Long,
  productType: Enum(Book, Movie),
  actionType: Enum(Click, Bookmark),

  // Поля-призраки:
  watchedPreview: Boolean?,  // Только для Movie
  pageId: Int?                // Только для Book
}

С ростом требований схема раздувается. Потребители вынуждены писать сложные if/else конструкции, чтобы понять, какие поля имеют смысл в данном контексте. Каждое новое productType добавляет новые nullable-поля, и схема превращается в нечитаемое месиво.

Паттерн: узконаправленные события

struct MovieClick {
  movieId: Long,
  watchedPreview: Boolean
}

struct BookClick {
  bookId: Long
}

struct BookBookmark {
  bookId: Long,
  pageId: Int
}

Каждое событие отражает один конкретный факт. Схемы чистые, строгие и не содержат лишних null полей. Для каждого типа события создаётся свой отдельный поток.

• • •

VIЛегитимный полиморфизм: oneof и union

Критика поля type не означает, что полиморфизм — это всегда зло. Если бизнес действительно требует вариативности в рамках одного потока (например, все события заказа в одном топике), форматы предоставляют типобезопасные инструменты.

Protobuf oneof

message OrderEvent {
  oneof event {
    OrderCreated created = 1;
    OrderPaid paid = 2;
    OrderShipped shipped = 3;
    OrderCancelled cancelled = 4;
  }
  int64 timestamp_ms = 5;
}

Avro union

{
  "type": "record",
  "name": "OrderEnvelope",
  "fields": [
    {
      "name": "event",
      "type": [
        "com.example.OrderCreated",
        "com.example.OrderPaid",
        "com.example.OrderShipped",
        "com.example.OrderCancelled"
      ]
    },
    { "name": "timestamp_ms", "type": "long" }
  ]
}

Разница с «ручным» полем type принципиальна: oneof/union обеспечивает типобезопасность на уровне компилятора. Варианты не накапливают nullable-поля — каждый вариант имеет собственную строгую схему. Проблема не в полиморфизме как таковом, а в nullable-полях-призраках.

Подводные камни oneof

В Protobuf oneof: нельзя использовать repeated; нельзя вкладывать oneof в oneof напрямую; смена варианта — это breaking change. Для Kafka при использовании oneof в одном топике применяйте стратегию TopicRecordNameStrategy — иначе Schema Registry не увидит поломку отдельного варианта.

• • •

VIIИменование событий: прошлое время и доменная привязка

Имя события — это первый контакт потребителя с контрактом. Сильное имя отвечает на два вопроса: какая доменная сущность изменилась и какой факт стал истинным.

  1. Прошедшее время. Событие констатирует факт: order.placed, payment.failed, customer.email.changed. Не createOrder (это команда) и не order_creating (это процесс).
  2. Доменная конкретика. user.updated — слабо. user.email_changed — лучше. customer.account.suspended — ещё лучше: указывает и на сущность, и на действие.
  3. Универсальный язык (Ubiquitous Language). Имя должно использовать терминологию предметной области: RowInserted — плохо, OrderItemAdded — хорошо.
  4. Стабильность. Имя должно пережить рефакторинг внутренней реализации. afterCreateHandlerExecuted хрупко, order.placed стабильно.

Антипаттерны: глаголы в настоящем времени (user:create), аббревиатуры понятные только команде-производителю, перегрузка одного субъекта разными смыслами (customer в контексте продаж и биллинга — это две разные модели).

• • •

VIIIЗолотые правила дизайна событий

  1. Событие должно быть самодостаточным. Если для обработки факта потребителю нужно идти в базу данных или вызывать другой API — событие спроектировано плохо.
  2. Один поток — одно определение. Не смешивайте разные по смыслу события в одном топике. Это усложняет валидацию, маршрутизацию и делает Schema Registry бесполезным.
  3. Точные типы данных. Не храните числа в String, не используйте Integer вместо булевых флагов.
  4. Минимизируйте размер. Событие содержит только то, что непосредственно относится к факту. Гигабайтный файл — передавайте ссылку (URI).
  5. Привлекайте потребителей к дизайну. Контракт двусторонний. Обсуждайте схемы с теми, кто будет их читать.
  6. Включайте event_id в каждое событие. Это основа идемпотентности: потребитель проверяет, не обработал ли он уже это событие.
  7. Передавайте ID, а не графики объектов. Потребителю нужен customer_id, а не весь профиль клиента с историей заказов.
• • •

IXСобытие — это факт, а не семафор

Если событие гласит { "taskId": 123, "status": "COMPLETED" }, но не содержит результата выполнения, вы создаете систему с двумя источниками истины. Потребителю придётся делать дополнительный запрос, чтобы узнать, что же именно произошло. Событие должно быть непреложным фактом, содержащим все необходимые данные для принятия решения.

Это не означает, что каждое событие должно быть «толстым». Balance между thin и fat events зависит от контекста:

Thin event

OrderUpdated { order_id: 42 } — лёгкий, но заставляет потребителя запрашивать детали. Подходит для уведомлений внутри одного bounded context, где потребитель имеет прямой доступ к данным.

Fat event

OrderUpdated { order_id, items, total, shipping_address } — тяжёлый, но полностью развязывает producer и consumer. Подходит для integration events, пересекающих границы сервисов.

Для событий, пересекающих границы сервисов (integration events), предпочитайте fat events — цена лишних байтов несоизмеримо ниже цены хрупкой синхронной связи.

• • •

XPII и чувствительные данные: события живут долго

События в потоке хранятся неделями, месяцами, иногда годами. Если в событие попадут паспортные данные, пароли или медицинская информация — это комплаенс-нарушение, которое невозможно «отозвать» после публикации.

  1. Минимизация — сильнее маскирования. Если downstream-потребителю поле не нужно для принятия решения — оно не должно попадать в событие.
  2. Tokenization вместо сырых значений. Вместо номера карты передавайте токен. Вместо email — хеш.
  3. Не кладите PII в имена потоков. user.john.doe@email.com.events — нельзя удалить без удаления всего потока. Используйте хеш или ID.
  4. Tombstone для GDPR-удаления. Когда пользователь просит удалить свои данные, в поток отправляется событие-маркер, после чего лог компактируется.
  5. Защищённые потоки для чувствительных событий. Не используйте широкий общий топик для событий с PII.
• • •

XIРазрушительные изменения: четыре стратегии миграции

Рано или поздно бизнес-модель меняется настолько, что старую схему не спасти. Главное правило: никогда не смешивайте несовместимые версии событий в одном потоке.

Стратегия 1: Expand-Migrate-Contract

Пошаговый паттерн для переименования или реструктуризации полей без даунтайма:

  1. Expand: добавьте новое поле рядом со старым. Оба заполняются.
  2. Migrate: обновите потребителей читать новое поле (с fallback на старое). Двойная запись.
  3. Contract: после того как все потребители перешли, удалите старое поле.

Стратегия 2: Dual publishing (двойная публикация)

Создайте новый поток с новой схемой и публикуйте события одновременно в старый и новый потоки:

  1. Объявите дедлайн (3–6 месяцев).
  2. Публикуйте события в оба потока.
  3. Отслеживайте, кто ещё читает старый поток.
  4. После миграции всех потребителей остановите публикацию в старый поток.
  5. Удалите старый поток после истечения retention-периода.

Важно: потребитель, подписанный на оба потока, должен понимать, что события дублируют друг друга, и применять дедупликацию по event_id или бизнес-ключу.

Стратегия 3: Adapter / Transformer

Промежуточный сервис читает старый поток, трансформирует события в новый формат и публикует в новый поток. Позволяет не трогать производителя — он продолжает писать в старом формате, а адаптер берёт на себя конвертацию.

Стратегия 4: Upcasting (для event sourcing)

В event-sourced системах старые события не переписываются физически. Вместо этого при чтении события «апкастятся» — преобразуются из старой версии схемы в текущую in-memory. Это функция читателя, а не хранилища.

function upcastV1toV2(event) {
  return {
    ...event,
    version: 2,
    shipping_address: event.address  // переименование
  };
}
• • •

XIIВерсионирование топиков: три подхода

Когда breaking change неизбежен, возникает вопрос: как разделить старую и новую версии? Существуют три подхода.

Подход Как работает Плюсы Минусы
Суффикс версии orders.v1, orders.v2 Просто, явно, легко отследить Дублирование инфраструктуры, сложнее в управлении
Разделение по имени orders (старый) + orders-v2 (новый) Гибко, можно перейти плавно Запутанная номенклатура, легко ошибиться
Один топик + стратегия orders с TopicRecordNameStrategy Один топик для всех версий, registry проверяет совместимость каждого типа Сложнее в отладке, требует дисциплины

Рекомендация: для breaking changes используйте суффикс версии (orders.v2). Для добавления новых типов событий в рамках одного домена — TopicRecordNameStrategy.

• • •

XIIIЧеклист перед публикацией события в production

  • Событие названо в прошедшем времени, содержит доменную сущность и конкретный факт
  • Схема зарегистрирована в Schema Registry, режим совместимости выбран осознанно
  • Конверт содержит event_id, occurred_at, correlation_id, causation_id
  • Событие самодостаточно — потребителю не нужен синхронный запрос к производителю
  • Нет PII в payload без необходимости; чувствительные данные токенизированы
  • Размер события минимизирован; большие объекты переданы по ссылке
  • Все поля имеют точные типы (не String для чисел, не Integer для булевых)
  • Проверена идемпотентность: потребитель корректно обрабатывает дубликаты по event_id
  • Документация события доступна в Event Catalog
  • При breaking change — запланирована миграция (dual publishing / adapter / expand-migrate-contract)
• • •

XIVСвязь с циклом и синтез

Эта глава — третья в цикле о событийно-управляемой архитектуре. Она опирается на две предыдущие и дополняет их.

Глава Фокус Связь с текущей главой
I. Среда формирует архитектуру Почему EDA и как это меняет организации Bounded context определяет, где thin events достаточны, а где нужны fat events
II. Анатомия EDA Как устроено под капотом: топологии, брокеры, идемпотентность Schema Registry и consumer group — техническая основа для контрактов
III. Анатомия идеального события Как проектировать события, живущие годами Схемы как мост между командами, замыкающий технический и организационный уровни

Чем событие отличается от API-контракта

Хотя событие и API-контракт — оба публичные интерфейсы, у события есть специфика:

  • Живёт дольше. Событие остаётся в потоке навсегда (или годами), а API-версия может быть отключена.
  • Нельзя «отозвать». Опубликованное событие невозможно удалить — только добавить компенсирующее.
  • Несколько потребителей, которых производитель не знает. Это делает правила совместимости строже, чем для API.
  • Асинхронность. Потребитель может обновляться через месяцы после производителя — registry защищает от этого разрыва.
Финальный тезис

В событийно-управляемой архитектуре схемы — это не просто техническая деталь сериализации. Это мост коммуникации между независимыми командами. Проектируя событие, задавайте себе вопрос: «Смогу ли я отдать эту схему другой команде, и смогут ли они понять её смысл без единого устного объяснения?» Если ответ «да» — вы на верном пути.

Глава III цикла «Событийно-управляемая архитектура». Источники: Confluent, Apicurio, AxonIQ, Habr, Microsoft Azure Architecture Center, практические кейсы production-систем.

FinCodeCow · Chapter III

The Anatomy of an Ideal Event

Contracts, schemas, and data evolution: how to design events that survive years of change

Claude Shannon, the father of information theory, formulated the fundamental problem of any communication: "The fundamental problem of communication is that of reproducing at one point either exactly or approximately a message selected at another point." In the world of event-driven microservices, this problem is solved by the data transfer contract.

An event is not just a packet of bytes flying across the network. It is a legal contract between producer and consumer. But unlike a REST API, where the version is visible in the URL, an event has no "business card" — its format is changed without thinking about the consumer somewhere who will parse the old field and crash in production.

This is the third chapter in the event-driven architecture series. The first answered "why," the second — "how it works under the hood." Now we will explore how to design events that survive business requirement changes, independent team deployments, and schema evolution for years to come.

• • •

IEnvelope and Payload: Event Anatomy Through the Lens of Metadata

Before talking about payload, we need to split the event into two layers: envelope and payload. Many teams design only the payload and forget that without a standardized envelope, distributed tracing turns into hell.

The envelope is metadata present in every event regardless of its business type:

Field Purpose
event_idUnique identifier (UUID v4/v7) for deduplication and idempotency
event_typeEvent name (e.g., order.placed) for routing and dispatching
event_versionSchema version for selecting processing logic
occurred_atTime of the business fact (not the time of publishing to the broker!)
correlation_idLinks all events of one distributed transaction
causation_idPoints to the specific event or command that caused this one
sourceProducer service name for audit and debugging
schema_refSchema ID in Schema Registry (for binary formats)

Correlation ID links all events spawned by a single user action: the user clicked "Place Order" → OrderPlaced, PaymentProcessed, InventoryReserved, ShippingScheduled — all carry the same correlation_id.

Causation ID is finer: it points to the specific event that caused the current one. PaymentProcessed caused OrderConfirmed. This allows reconstructing the full causal chain — the equivalent of a stack trace for the asynchronous world.

{
  "event_id": "evt_0192f8a3-...",
  "event_type": "order.placed",
  "event_version": 2,
  "occurred_at": "2026-09-29T12:00:00Z",
  "correlation_id": "req-abc-123",
  "causation_id": "evt_0192f8a1-...",
  "source": "checkout-service",
  "schema_ref": 42,
  "payload": {
    "order_id": "ord_92814",
    "customer_id": "cus_42",
    "total_amount": 149.90,
    "currency": "RUB"
  }
}
Key Principle

The envelope is stable and independent of the business type. The payload changes per schema. Separating these layers allows changing business logic without rewriting the tracing and deduplication infrastructure.

• • •

IIThree Formats, Three Philosophies: Avro, Protobuf, JSON Schema

Any contract in EDM rests on two pillars: data definition (schema) — which fields, types, and structures are transmitted, and trigger logic — why and under what business conditions the event was generated. If you don't use an explicit schema (e.g., just send a JSON string), you rely on "tribal knowledge" and verbal agreements.

Formats like Apache Avro, Protobuf, and JSON Schema have become the de facto standard because they solve the main pain of microservices — independent deployment. But choosing a format is not just about message size.

Criterion Avro Protobuf JSON Schema
Strength Flexible schema evolution Performance and strict typing Readability, low entry barrier
Message size Compact (30–40% smaller than JSON) Most compact (20–30% smaller than Avro) 3–5× larger than binary
Compatibility key Writer/reader schema matching via registry Persistent field numbers (tag) External validation
Code generation Not required (dynamic typing) Required (.proto → classes) Not required
Typical use Kafka, Data Lakes, ETL gRPC, high-load microservices REST API, config, debugging

Selection rule: Avro — when schema evolution is critical and Schema Registry is available. Protobuf — when maximum performance and strict typing at the compiler level are needed. JSON Schema — when readability matters more than compactness.

Real numbers per 1M events/day, 365-day retention, replication factor 3: JSON — ~50 GB/year, Avro — ~12 GB, Protobuf — ~10 GB.

Important nuance: Avro supports aliases — a mechanism for renaming a field without a breaking change:

{
  "name": "customer_email",
  "type": "string",
  "aliases": ["email", "customerEmail"]
}

Old consumers reading the email field will continue to work after renaming to customer_email.

• • •

IIISchema Evolution: Three Compatibility Levels

Evolution rules allow producers and consumers to update at different times. But "compatibility" is not a single concept. There are three basic modes and three transitive extensions.

Backward

New consumer reads data written with the old schema. Allowed: adding optional fields with default, removing fields, renaming via aliases. Forbidden: adding required fields without default, changing type.

Forward

Old consumer reads data with the new schema. Allowed: adding fields, removing optional fields. Forbidden: removing required fields, changing type.

Full

Combination of both. Safest but most restrictive: only adding and removing optional fields with defaults.

Transitive

BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE check compatibility with all versions in history. Critical for event sourcing and long retention.

Change Backward Forward Full
Add optional field with defaultYesYesYes
Add required fieldNoNoNo
Remove optional fieldYesYesYes
Remove required fieldNoYesNo
Rename fieldOnly via aliasesOnly via aliasesNo
Change field typeUsually noUsually noNo
Which Mode to Choose

BACKWARD — if you update consumers first. FORWARD — if you update producers first. FULL — if teams deploy independently and in any order. TRANSITIVE — if events are stored long and must be read years ahead.

• • •

IVSchema Registry and Event Catalog: Contract Infrastructure

Compatibility rules without an enforcement tool are just a hope that "someone won't forget." Schema Registry turns rules into a mandatory mechanism.

How it works: the producer registers the schema in the registry before publishing. The registry checks compatibility of the new version with previous ones (or with all — in transitive mode). If incompatible, the registry rejects the publication — the error occurs at deploy time, not in production at the consumer. Only the schema ID (4–5 bytes) is transmitted in the message, not the entire schema.

Main Schema Registry implementations:

Solution Type When to Choose
Confluent Schema Registry Commercial + Community Confluent ecosystem, deep Kafka integration
Apicurio Registry Open-source (Red Hat) Vendor independence, Avro/Protobuf/JSON Schema support
AWS Glue Schema Registry Managed (AWS) Serverless stack, native AWS services
Karapace Open-source (Aiven) Drop-in replacement for Confluent Schema Registry

Event Catalog — A Catalog of Business Meaning

Schema Registry is technical infrastructure. But organizations with dozens of teams need an Event Catalog — a catalog describing not only schemas but also business meaning, owners, consumers, examples.

Schema Registry

Stores schemas, checks compatibility, serves versions. Answers "what does the event look like."

Event Catalog

Describes business meaning, owner, consumers, examples, SLA. Answers "what does the event mean and who uses it."

Tools: DataHub, Backstage, Confluent Stream Catalog. Both layers complement each other.

• • •

VThe Trap of the type Field: Anatomy of Bad Design

One of the most common mistakes is trying to create a "universal" event by adding a type field to cover several business scenarios at once. This violates single responsibility and turns the schema into a dump of nullable fields.

Antipattern: universal event

struct ProductEngagement {
  productId: Long,
  productType: Enum(Book, Movie),
  actionType: Enum(Click, Bookmark),

  // Ghost fields:
  watchedPreview: Boolean?,  // Only for Movie
  pageId: Int?                // Only for Book
}

As requirements grow, the schema bloats. Consumers must write complex if/else to understand which fields make sense. Each new productType adds more nullable fields.

Pattern: narrow events

struct MovieClick {
  movieId: Long,
  watchedPreview: Boolean
}

struct BookClick {
  bookId: Long
}

struct BookBookmark {
  bookId: Long,
  pageId: Int
}

Each event reflects one specific fact. Schemas are clean, strict, and free of extra null fields.

• • •

VILegitimate Polymorphism: oneof and union

Criticism of the type field does not mean polymorphism is always evil. If the business genuinely requires variability within a single stream, formats provide type-safe tools.

Protobuf oneof

message OrderEvent {
  oneof event {
    OrderCreated created = 1;
    OrderPaid paid = 2;
    OrderShipped shipped = 3;
    OrderCancelled cancelled = 4;
  }
  int64 timestamp_ms = 5;
}

Avro union

{
  "type": "record",
  "name": "OrderEnvelope",
  "fields": [
    {
      "name": "event",
      "type": [
        "com.example.OrderCreated",
        "com.example.OrderPaid",
        "com.example.OrderShipped",
        "com.example.OrderCancelled"
      ]
    },
    { "name": "timestamp_ms", "type": "long" }
  ]
}

The difference from a "manual" type field is fundamental: oneof/union provides type safety at the compiler level. The problem is not polymorphism as such, but ghost nullable fields.

oneof Pitfalls

In Protobuf oneof: repeated cannot be used; nested oneof is not directly allowed; changing a variant is a breaking change. For Kafka, when using oneof in one topic, apply TopicRecordNameStrategy.

• • •

VIIEvent Naming: Past Tense and Domain Binding

The event name is the consumer's first contact with the contract. A strong name answers two questions: which domain entity changed and which fact became true.

  1. Past tense. order.placed, payment.failed, customer.email.changed. Not createOrder (a command) and not order_creating (a process).
  2. Domain specificity. user.updated — weak. user.email_changed — better. customer.account.suspended — even better.
  3. Ubiquitous Language. Use domain terminology: RowInserted — bad, OrderItemAdded — good.
  4. Stability. The name must survive internal refactoring. order.placed is stable.
• • •

VIIIGolden Rules of Event Design

  1. Events must be self-sufficient. If a consumer needs to hit a database or another API — the event is badly designed.
  2. One stream — one definition. Don't mix different events in one topic.
  3. Exact data types. Don't store numbers as String.
  4. Minimize size. Pass large objects by reference (URI).
  5. Involve consumers in the design. The contract is two-sided.
  6. Include event_id in every event. Foundation of idempotency.
  7. Pass IDs, not object graphs. The consumer needs customer_id, not the entire profile.
• • •

IXAn Event Is a Fact, Not a Semaphore

If an event says { "taskId": 123, "status": "COMPLETED" } but doesn't contain the result, you create a system with two sources of truth. The event must be an immutable fact containing all the data needed to make a decision.

Thin event

OrderUpdated { order_id: 42 } — light, but forces the consumer to query details. Suitable within a bounded context.

Fat event

OrderUpdated { order_id, items, total, shipping_address } — heavy, but fully decouples producer and consumer. Suitable for integration events.

For events crossing service boundaries, prefer fat events — the cost of extra bytes is incomparably lower than the cost of fragile synchronous coupling.

• • •

XPII and Sensitive Data: Events Live Long

Events in the stream are stored for weeks, months, sometimes years. If passport data, passwords, or medical information end up in an event, it is a compliance violation that cannot be "recalled" after publication.

  1. Minimization beats masking. If a downstream consumer doesn't need a field to make a decision, it shouldn't be in the event.
  2. Tokenization instead of raw values. Pass a token instead of a card number, a hash instead of an email.
  3. Don't put PII in stream names. Use a hash or ID.
  4. Tombstone for GDPR deletion. Send a marker event, then compact the log.
  5. Protected streams for sensitive events. Don't use a broad common topic.
• • •

XIBreaking Changes: Four Migration Strategies

Sooner or later the business model changes so much that the old schema cannot be saved. Main rule: never mix incompatible event versions in one stream.

Strategy 1: Expand-Migrate-Contract

  1. Expand: add a new field next to the old one. Both are filled.
  2. Migrate: update consumers to read the new field (with fallback). Dual write.
  3. Contract: after all consumers migrate, remove the old field.

Strategy 2: Dual publishing

  1. Announce a deadline (3–6 months).
  2. Publish events to both streams.
  3. Track who still reads the old stream.
  4. After all consumers migrate, stop publishing to the old stream.
  5. Delete the old stream after the retention period.

Strategy 3: Adapter / Transformer

An intermediate service reads the old stream, transforms events into the new format, and publishes them to the new stream. The producer is untouched.

Strategy 4: Upcasting (for event sourcing)

In event-sourced systems, old events are not physically rewritten. Instead, on read, events are "upcast" from the old schema version to the current one in memory. This is a reader function, not a storage function.

function upcastV1toV2(event) {
  return {
    ...event,
    version: 2,
    shipping_address: event.address  // rename
  };
}
• • •

XIITopic Versioning: Three Approaches

Approach How It Works Pros Cons
Version suffix orders.v1, orders.v2 Simple, explicit Infrastructure duplication
Name separation orders (old) + orders-v2 (new) Flexible, smooth transition Confusing naming
One topic + strategy orders with TopicRecordNameStrategy One topic for all versions Harder to debug

Recommendation: for breaking changes, use a version suffix (orders.v2). For adding new event types within one domain — TopicRecordNameStrategy.

• • •

XIIIPre-Production Checklist

  • The event is named in past tense, contains a domain entity and a specific fact
  • The schema is registered in Schema Registry, compatibility mode chosen consciously
  • The envelope contains event_id, occurred_at, correlation_id, causation_id
  • The event is self-sufficient — no synchronous query to the producer needed
  • No PII in payload without necessity; sensitive data tokenized
  • Event size minimized; large objects passed by reference
  • All fields have exact types (not String for numbers, not Integer for booleans)
  • Idempotency verified: consumer correctly handles duplicates by event_id
  • Event documentation available in the Event Catalog
  • For breaking changes — migration planned (dual publishing / adapter / expand-migrate-contract)
• • •

XIVLink to the Series and Synthesis

This is the third chapter in the event-driven architecture series. It builds on the two previous ones and complements them.

Chapter Focus Link to This Chapter
I. The Medium Shapes the Architecture Why EDA and how it changes organizations Bounded context determines where thin events suffice and where fat events are needed
II. The Anatomy of EDA How it works under the hood: topologies, brokers, idempotency Schema Registry and consumer group — technical foundation for contracts
III. The Anatomy of an Ideal Event How to design events that live for years Schemas as a bridge between teams, closing the technical and organizational levels

How an Event Differs from an API Contract

Although an event and an API contract are both public interfaces, events have their own specifics:

  • Lives longer. An event remains in the stream forever (or for years), while an API version can be decommissioned.
  • Cannot be "recalled." A published event cannot be deleted — only compensated.
  • Multiple consumers unknown to the producer. This makes compatibility rules stricter than for APIs.
  • Asynchrony. A consumer may update months after the producer — the registry protects against this gap.
Final Thesis

In event-driven architecture, schemas are not just a technical detail of serialization. They are a bridge of communication between independent teams. When designing an event, ask yourself: "Will I be able to hand this schema to another team, and will they understand its meaning without a single verbal explanation?" If the answer is yes — you are on the right path.

Chapter III of the "Event-Driven Architecture" series. Sources: Confluent, Apicurio, AxonIQ, Habr, Microsoft Azure Architecture Center, production system case studies.