Отец теории информации Клод Шеннон сформулировал главную проблему любой коммуникации: «Фундаментальная проблема обмена информацией состоит в том, чтобы в одной точке точно или приблизительно воспроизвести сообщение, выбранное в другой точке». В мире событийно-управляемых микросервисов эту проблему решает контракт на передачу данных.
Событие — это не просто пакет байтов, летящий по сети. Это юридический контракт между производителем и потребителем. Но в отличие от REST API, где версию видно в URL, у события нет «визитной карточки» — формат меняют, не задумываясь о том, что где-то сидит потребитель, который распарсит старое поле и упадёт в продакшене.
Эта глава — третья в цикле о событийно-управляемой архитектуре. Первая отвечала на вопрос «почему», вторая — «как устроено под капотом». Теперь мы разберём, как создавать события, которые переживут смену бизнес-требований, независимое развёртывание команд и эволюцию схем на годы вперёд.
Прежде чем говорить о payload, нужно разделить событие на два слоя: конверт (envelope) и груз (payload). Многие команды проектируют только payload и забывают, что без стандартизованного конверта распределённая трассировка превращается в ад.
Конверт — это метаданные, которые присутствуют в каждом событии независимо от его бизнес-типа:
| Поле | Назначение |
|---|---|
event_id | Уникальный идентификатор (UUID v4/v7) для дедупликации и идемпотентности |
event_type | Имя события (например, order.placed) для маршрутизации и диспетчеризации |
event_version | Версия схемы для выбора логики обработки |
occurred_at | Время свершения бизнес-факта (не время публикации в брокер!) |
correlation_id | Связывает все события одной распределённой транзакции |
causation_id | Указывает на конкретное событие или команду, которая причиной данного события |
source | Имя сервиса-производителя для аудита и отладки |
schema_ref | ID схемы в 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 меняется по схеме. Разделение этих слоёв позволяет менять бизнес-логику, не переписывая инфраструктуру трассировки и дедупликации.
Любой контракт в 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-полей.
Поддержка правил эволюции позволяет производителям и потребителям обновляться в разное время. Но «совместимость» — это не единое понятие. Существуют три базовых режима и три транзитивных расширения.
Новый потребитель читает данные, записанные по старой схеме. Разрешено: добавление опциональных полей с default, удаление полей, смена имени через aliases. Запрещено: добавление обязательных полей без default, смена типа.
Старый потребитель читает данные по новой схеме. Разрешено: добавление полей (старый потребитель их игнорирует), удаление опциональных полей. Запрещено: удаление обязательных полей, смена типа.
Комбинация обеих. Самый безопасный режим, но и самый ограничительный: только добавление и удаление опциональных полей со значениями по умолчанию.
BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE проверяют совместимость со всеми версиями в истории, а не только с предыдущей. Критично для event sourcing и долгого хранения.
| Изменение | Backward | Forward | Full |
|---|---|---|---|
| Добавить опциональное поле с default | Да | Да | Да |
| Добавить обязательное поле | Нет | Нет | Нет |
| Удалить опциональное поле | Да | Да | Да |
| Удалить обязательное поле | Нет | Да | Нет |
| Переименовать поле | Только через aliases | Только через aliases | Нет |
| Изменить тип поля | Обычно нет | Обычно нет | Нет |
BACKWARD — если обновляете потребителей первыми. FORWARD — если обновляете производителей первыми. FULL — если команды деплоятся независимо и в произвольном порядке. TRANSITIVE — если события хранятся долго и должны читаться годами вперёд.
Правила совместимости без инструмента 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 |
Schema Registry — это техническая инфраструктура. Но для организаций с десятками команд нужен Event Catalog — каталог, где описаны не только схемы, но и бизнес-смысл событий, владельцы, потребители, примеры.
Хранит схемы, проверяет совместимость, выдаёт версии. Отвечает на вопрос «как выглядит событие».
Описывает бизнес-смысл, владельца, потребителей, примеры, SLA. Отвечает на вопрос «что означает событие и кто его использует».
Примеры инструментов: DataHub, Backstage (с плагинами для событий), Confluent Stream Catalog. Оба слоя — registry и catalog — дополняют друг друга: без registry контракты не защищены, без catalog они непонятны.
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 полей. Для каждого типа события создаётся свой отдельный поток.
oneof и unionКритика поля type не означает, что полиморфизм — это всегда зло. Если бизнес действительно требует вариативности в рамках одного потока (например, все события заказа в одном топике), форматы предоставляют типобезопасные инструменты.
oneofmessage OrderEvent {
oneof event {
OrderCreated created = 1;
OrderPaid paid = 2;
OrderShipped shipped = 3;
OrderCancelled cancelled = 4;
}
int64 timestamp_ms = 5;
}
{
"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-полях-призраках.
В Protobuf oneof: нельзя использовать repeated; нельзя вкладывать oneof в oneof напрямую; смена варианта — это breaking change. Для Kafka при использовании oneof в одном топике применяйте стратегию TopicRecordNameStrategy — иначе Schema Registry не увидит поломку отдельного варианта.
Имя события — это первый контакт потребителя с контрактом. Сильное имя отвечает на два вопроса: какая доменная сущность изменилась и какой факт стал истинным.
order.placed, payment.failed, customer.email.changed. Не createOrder (это команда) и не order_creating (это процесс).user.updated — слабо. user.email_changed — лучше. customer.account.suspended — ещё лучше: указывает и на сущность, и на действие.RowInserted — плохо, OrderItemAdded — хорошо.afterCreateHandlerExecuted хрупко, order.placed стабильно.Антипаттерны: глаголы в настоящем времени (user:create), аббревиатуры понятные только команде-производителю, перегрузка одного субъекта разными смыслами (customer в контексте продаж и биллинга — это две разные модели).
String, не используйте Integer вместо булевых флагов.event_id в каждое событие. Это основа идемпотентности: потребитель проверяет, не обработал ли он уже это событие.customer_id, а не весь профиль клиента с историей заказов.Если событие гласит { "taskId": 123, "status": "COMPLETED" }, но не содержит результата выполнения, вы создаете систему с двумя источниками истины. Потребителю придётся делать дополнительный запрос, чтобы узнать, что же именно произошло. Событие должно быть непреложным фактом, содержащим все необходимые данные для принятия решения.
Это не означает, что каждое событие должно быть «толстым». Balance между thin и fat events зависит от контекста:
OrderUpdated { order_id: 42 } — лёгкий, но заставляет потребителя запрашивать детали. Подходит для уведомлений внутри одного bounded context, где потребитель имеет прямой доступ к данным.
OrderUpdated { order_id, items, total, shipping_address } — тяжёлый, но полностью развязывает producer и consumer. Подходит для integration events, пересекающих границы сервисов.
Для событий, пересекающих границы сервисов (integration events), предпочитайте fat events — цена лишних байтов несоизмеримо ниже цены хрупкой синхронной связи.
События в потоке хранятся неделями, месяцами, иногда годами. Если в событие попадут паспортные данные, пароли или медицинская информация — это комплаенс-нарушение, которое невозможно «отозвать» после публикации.
user.john.doe@email.com.events — нельзя удалить без удаления всего потока. Используйте хеш или ID.Рано или поздно бизнес-модель меняется настолько, что старую схему не спасти. Главное правило: никогда не смешивайте несовместимые версии событий в одном потоке.
Пошаговый паттерн для переименования или реструктуризации полей без даунтайма:
Создайте новый поток с новой схемой и публикуйте события одновременно в старый и новый потоки:
Важно: потребитель, подписанный на оба потока, должен понимать, что события дублируют друг друга, и применять дедупликацию по event_id или бизнес-ключу.
Промежуточный сервис читает старый поток, трансформирует события в новый формат и публикует в новый поток. Позволяет не трогать производителя — он продолжает писать в старом формате, а адаптер берёт на себя конвертацию.
В event-sourced системах старые события не переписываются физически. Вместо этого при чтении события «апкастятся» — преобразуются из старой версии схемы в текущую in-memory. Это функция читателя, а не хранилища.
function upcastV1toV2(event) {
return {
...event,
version: 2,
shipping_address: event.address // переименование
};
}
Когда breaking change неизбежен, возникает вопрос: как разделить старую и новую версии? Существуют три подхода.
| Подход | Как работает | Плюсы | Минусы |
|---|---|---|---|
| Суффикс версии | orders.v1, orders.v2 |
Просто, явно, легко отследить | Дублирование инфраструктуры, сложнее в управлении |
| Разделение по имени | orders (старый) + orders-v2 (новый) |
Гибко, можно перейти плавно | Запутанная номенклатура, легко ошибиться |
| Один топик + стратегия | orders с TopicRecordNameStrategy |
Один топик для всех версий, registry проверяет совместимость каждого типа | Сложнее в отладке, требует дисциплины |
Рекомендация: для breaking changes используйте суффикс версии (orders.v2). Для добавления новых типов событий в рамках одного домена — TopicRecordNameStrategy.
event_id, occurred_at, correlation_id, causation_idString для чисел, не Integer для булевых)event_idЭта глава — третья в цикле о событийно-управляемой архитектуре. Она опирается на две предыдущие и дополняет их.
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| I. Среда формирует архитектуру | Почему EDA и как это меняет организации | Bounded context определяет, где thin events достаточны, а где нужны fat events |
| II. Анатомия EDA | Как устроено под капотом: топологии, брокеры, идемпотентность | Schema Registry и consumer group — техническая основа для контрактов |
| III. Анатомия идеального события | Как проектировать события, живущие годами | Схемы как мост между командами, замыкающий технический и организационный уровни |
Хотя событие и API-контракт — оба публичные интерфейсы, у события есть специфика:
В событийно-управляемой архитектуре схемы — это не просто техническая деталь сериализации. Это мост коммуникации между независимыми командами. Проектируя событие, задавайте себе вопрос: «Смогу ли я отдать эту схему другой команде, и смогут ли они понять её смысл без единого устного объяснения?» Если ответ «да» — вы на верном пути.
Глава III цикла «Событийно-управляемая архитектура». Источники: Confluent, Apicurio, AxonIQ, Habr, Microsoft Azure Architecture Center, практические кейсы production-систем.
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.
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_id | Unique identifier (UUID v4/v7) for deduplication and idempotency |
event_type | Event name (e.g., order.placed) for routing and dispatching |
event_version | Schema version for selecting processing logic |
occurred_at | Time of the business fact (not the time of publishing to the broker!) |
correlation_id | Links all events of one distributed transaction |
causation_id | Points to the specific event or command that caused this one |
source | Producer service name for audit and debugging |
schema_ref | Schema 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"
}
}
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.
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.
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.
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.
Old consumer reads data with the new schema. Allowed: adding fields, removing optional fields. Forbidden: removing required fields, changing type.
Combination of both. Safest but most restrictive: only adding and removing optional fields with defaults.
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 default | Yes | Yes | Yes |
| Add required field | No | No | No |
| Remove optional field | Yes | Yes | Yes |
| Remove required field | No | Yes | No |
| Rename field | Only via aliases | Only via aliases | No |
| Change field type | Usually no | Usually no | No |
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.
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 |
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.
Stores schemas, checks compatibility, serves versions. Answers "what does the event look like."
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.
type Field: Anatomy of Bad DesignOne 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.
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.
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.
oneof and unionCriticism 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.
oneofmessage OrderEvent {
oneof event {
OrderCreated created = 1;
OrderPaid paid = 2;
OrderShipped shipped = 3;
OrderCancelled cancelled = 4;
}
int64 timestamp_ms = 5;
}
{
"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.
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.
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.
order.placed, payment.failed, customer.email.changed. Not createOrder (a command) and not order_creating (a process).user.updated — weak. user.email_changed — better. customer.account.suspended — even better.RowInserted — bad, OrderItemAdded — good.order.placed is stable.String.event_id in every event. Foundation of idempotency.customer_id, not the entire profile.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.
OrderUpdated { order_id: 42 } — light, but forces the consumer to query details. Suitable within a bounded context.
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.
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.
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.
An intermediate service reads the old stream, transforms events into the new format, and publishes them to the new stream. The producer is untouched.
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
};
}
| 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.
event_id, occurred_at, correlation_id, causation_idString for numbers, not Integer for booleans)event_idThis 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 |
Although an event and an API contract are both public interfaces, events have their own specifics:
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.