Если первая глава отвечала на вопрос «почему» бизнесу нужны событийно-управляемые микросервисы, то теперь пришло время разобрать «как» они устроены под капотом. Событийно-управляемая архитектура (EDA) — это не просто очередь сообщений, скрытая за красивым API. Это принципиально иная парадигма работы с данными, где каждый факт становится неизменяемой частью истории, а сервисы выступают в роли независимых наблюдателей и преобразователей этой истории.
В мире EDM термин «топология» используется для описания архитектуры на двух совершенно разных уровнях. Понимание разницы между ними критически важно для проектирования.
Топология микросервиса — это «внутренняя кухня» одного конкретного сервиса. Она описывает, как входящие события фильтруются, трансформируются, обогащаются и в конечном итоге публикуются в исходящие потоки. Это граф операций, управляемых данными, где узлами выступают стандартные преобразования:
Отбрасывание событий, не релевантных для данного сервиса.
Преобразование структуры события — добавление вычисляемых полей, смена формата.
Обогащение события данными из другого потока или локального стейта.
Свёртка нескольких событий в одно — например, подсчёт суммы заказа.
Разбиение одного события на несколько — по товарным позициям заказа.
Группировка событий по временному окну для аналитики и паттернов.
Бизнес-топология — это «карта города». Она показывает, как множество независимых микросервисов, потоков событий и REST API взаимодействуют друг с другом для выполнения сложных сквозных бизнес-процессов. Здесь микросервисы — это узлы, а потоки событий — дороги между ними.
Принципиальное отличие бизнес-топологии EDA от обычной диаграммы REST-вызовов заключается в модели распространения данных. В REST-сети каждый вызов — это «точка-точка»: сервис А вызывает сервис Б, и больше никто об этом не знает. В событийной топологии один поток могут одновременно читать десятки независимых потребителей в режиме pub-sub: сервис уведомлений, сервис аналитики, сервис биллинга и поисковый индексатор — все подписаны на один и тот же поток OrderEvents, и ни один из них не знает о существовании остальных.
Добавление нового потребителя не требует ни изменения кода производителя, ни координации между командами. Вместо жёсткого графа вызовов получается гибкая сеть подписок. Именно это делает событийную топологию принципиально иной.
Событие — это констатация факта. В отличие от классических систем передачи сообщений, где сообщение — это команда «сделай что-то», событие говорит: «вот что уже произошло». События всегда называются в прошедшем времени (OrderPlaced, PaymentProcessed, CustomerRegistered), а не в повелительном наклонении — это не случайно, а чтобы подчеркнуть их природу: факт уже свершён и не может быть отменён.
В практике EDA события принято классифицировать не по наличию ключа, а по семантике — тому, какой объём информации они несут и как потребители должны с ними работать.
| Тип события | Что несёт | Плюсы | Минусы |
|---|---|---|---|
| Domain Event | Полный бизнес-факт: ID, контекст, все поля | Потребитель обрабатывает без дополнительных запросов | Большой объём, потенциальная утечка чувствительных данных |
| Event Notification | Лёгкий сигнал: ID + что изменилось | Минимум данных, защита приватности | Дополнительная нагрузка на источник, риск thundering herd |
| Event-Carried State Transfer | Snapshot или delta состояния сущности | Максимальная развязка, потребителю не нужен источник | Дублирование состояния, объём |
Domain Events — значимые бизнес-факты, о которых заботятся эксперты предметной области. Например, OrderPlaced включает ID заказа, ID клиента, список позиций, сумму и временную метку. Это «богатые» события, которые несут в себе полноценный контекст произошедшего изменения.
Event Notification Events — лёгкие сигналы, которые лишь сообщают, что что-то произошло, не передавая подробностей. Например, CustomerProfileUpdated может содержать только ID клиента и секцию, которая изменилась (ADDRESS), заставляя потребителя запросить актуальное состояние через API.
Event-Carried State Transfer Events — события, которые несут внутри себя snapshot или delta состояния сущности, позволяя потребителям обновлять свои локальные проекции без обращения к источнику. Именно этот тип событий лежит в основе материализованных проекций и CQRS.
Независимо от семантического типа, событие обычно содержит ключ — идентификатор сущности, к которой оно относится. Ключ определяет, в какую партицию потока попадёт событие, и гарантирует, что все события, относящиеся к одному объекту или пользователю, будут обрабатываться в строгом порядке.
Одна из самых мощных концепций в EDM — это возможность материализовать поток событий в таблицу состояний (просто применяя события по порядку) и, наоборот, превращать изменения в таблице обратно в поток событий. Поток — это история изменений; таблица — это текущее состояние, вычисленное из этой истории. Это две формы одних и тех же данных, и переход между ними — чистая функция.
Практическое применение этой дуальности — паттерн CQRS (Command Query Responsibility Segregation). Сервис подписывается на поток событий (например, OrderEvents), материализует из него локальную таблицу-проекцию (read model) и отдаёт её через REST API. Запись и чтение разделены: команды изменяют состояние через события, а запросы читают из оптимизированных проекций.
Это позволяет независимо масштабировать сторону записи и сторону чтения, а также адаптировать структуру проекции под конкретные запросы — денормализовать, индексировать, кэшировать.
Данные не просто копируются — они фильтруются, агрегируются и пересчитываются под нужды конкретного потребителя. Например, сервис рекомендаций может построить из потока заказов таблицу «топ-10 категорий по частоте покупок для каждого клиента», которая физически не существует в источнике.
Если сущность удаляется, в поток отправляется специальное событие — tombstone (метка об удалении). Потребители, материализовавшие проекции из этого потока, при получении tombstone очищают соответствующие записи в своих локальных хранилищах. В Kafka для этого существует режим log compaction: брокер сохраняет только последнее значение для каждого ключа, удаляя промежуточные записи, что превращает поток в компактный snapshot текущего состояния.
Многие разработчики по привычке путают брокеры событий (Event Brokers, например, Apache Kafka, Redpanda) с классическими брокерами сообщений (Message Brokers, например, RabbitMQ, ActiveMQ). Разница между ними фундаментальна.
Работает как диспетчер: получает сообщение, ждёт, пока потребитель его заберёт, и уничтожает после подтверждения. Модель push или pull, но сообщение одноразовое.
Работает как неизменяемый журнал фактов. Сохраняет каждое событие навсегда (или согласно retention), позволяя новым сервисам проиграть историю с любой точки.
Ключевые требования к современному брокеру событий:
Выбор брокера — не религиозный вопрос, а инженерный. Ниже — сравнение основных игроков рынка.
| Брокер | Модель | Когда выбирать | Ограничения |
|---|---|---|---|
| Apache Kafka | Распределённый лог, pull-модель | Потоки событий, Event Sourcing, CQRS, аналитика, миллионы сообщений/сек | Сложная эксплуатация, не для простых очередей задач |
| Redpanda | Kafka-совместимый, C++ | Низкая задержка, простота эксплуатации, замена Kafka без переписывания кода | Меньше экосистема, не все Kafka-фичи 1:1 |
| Apache Pulsar | Лог + очереди, multi-tenancy | Мультиарендность, геораспределение, tiered storage | Меньше специалистов, сложнее в настройке |
| NATS JetStream | Лёгкий стриминг | Edge, IoT, микросервисы с низкой задержкой | Меньше гарантий и инструментов для тяжёлого Event Sourcing |
| RabbitMQ | Классический брокер, push-модель | Очереди задач, RPC, маршрутизация по правилам | Сообщение уничтожается после ack — не для Event Sourcing |
| AWS EventBridge | Managed event bus | Serverless, AWS-нативные системы | Vendor lock-in, ограничения на порядок и хранение |
Практическое правило: если нужна очередь задач — RabbitMQ или SQS. Если нужен поток фактов — Kafka, Redpanda, Pulsar или EventStoreDB. Смешивать роли в одном инструменте — путь к distributed big ball of mud.
Брокер событий предоставляет данные потребителям двумя основными способами, в зависимости от бизнес-задачи.
Каждый потребитель сам управляет своим «смещением» (offset) — указателем на то, какое событие он прочитал последним. Несколько независимых групп потребителей (consumer groups) могут читать один и тот же поток, не мешая друг другу: каждая группа поддерживает собственный offset и движется в своём темпе.
Например, сервис инвентаризации и сервис уведомлений могут быть двумя разными consumer groups, читающими один и тот же топик OrderEvents. Сервис инвентаризации обрабатывает события синхронно и держит offset близко к «хвосту» потока. Сервис уведомлений может отставать на часы при пиковых нагрузках — и это нормально, события не потеряются.
Событие обрабатывается только одним экземпляром потребителя из пула. В Kafka это достигается через consumer group: все консьюмеры с одинаковым group.id делят партиции топика между собой. Если потребителей больше, чем партиций, «лишние» простаивают. Если потребитель падает, его партиции перераспределяются — этот процесс называется rebalance.
Здесь порядок может нарушаться между партициями, но зато обеспечивается честное распределение нагрузки. Отлично подходит для фоновых задач, рассылок и интеграций.
При eager rebalance все потребители в группе временно останавливают чтение, отдают партиции и получают новые. Это окно недоступности может длиться секунды. Cooperative Sticky Assignor минимизирует проблему, перераспределяя только нужные партиции, — остальным не нужно останавливаться. А static group membership (group.instance.id) позволяет потребителю при рестарте получить те же партиции без полного ребаланса.
Offset — это позиция потребителя в партиции. Kafka не отслеживает прогресс потребителя автоматически — это ответственность самого потребителя. Потребитель регулярно коммитит offset, отмечая, какие события он успешно обработал.
| Стратегия | Гарантия | Риск |
|---|---|---|
| Auto-commit (до обработки) | At-most-once | Потеря сообщений при crashes между коммитом и обработкой |
| Manual commit (после обработки) | At-least-once | Дубликаты при crash до коммита |
| Transactional (Kafka EOS) | Exactly-once | Сложность, накладные расходы |
При rebalance потребитель, потерявший партицию до коммита, передаёт её новому владельцу, который начнёт с последнего закоммиченного offset — и события, уже обработанные, но не закоммиченные, будут обработаны повторно. Это нормально для at-least-once, но означает, что потребители должны быть идемпотентными.
В распределённых системах дубликаты — это не баг, а норма. Потребитель упал до коммита offset, произошёл rebalance, сообщение обработалось заново. Сетевой сбой вызвал ретрай продюсера, и брокер записал событие дважды. Результат: потребитель увидит одно и то же событие как минимум дважды за время жизни системы.
Защита от дубликатов строится на нескольких уровнях:
enable.idempotence=true (с Kafka 3.0 — по умолчанию).transactional.id, обработка и коммит offset происходят атомарно.Пример идемпотентного потребителя с таблицей дедупликации:
BEGIN TRANSACTION;
IF EXISTS (SELECT 1 FROM processed_events WHERE event_id = :id) THEN
COMMIT;
RETURN;
END IF;
INSERT INTO processed_events (event_id, processed_at)
VALUES (:id, NOW());
UPDATE balances SET amount = amount + :delta
WHERE account_id = :account_id;
COMMIT;
Практическое правило: at-least-once + идемпотентный потребитель = effectively exactly-once. Это проще и надёжнее, чем транзакционный EOS, для большинства сценариев.
Даже при строгой упорядоченности внутри партиции, потребитель может столкнуться с нарушением порядка на нескольких уровнях:
Защита — встраивание sequence number в payload события. Потребитель сравнивает полученный номер с ожидаемым и:
Ожидаемый 5, получен 7. Потребитель либо ждёт событие 6 в буфере, либо фиксирует алерт и переходит в режим восстановления.
Ожидаемый 7, получен 5. Событие уже обработано или потеряно. Потребитель отбрасывает его, чтобы не откатить состояние.
Sequence number может быть:
Для критичных сценариев (финансовые транзакции, инвентаризация) это необходимая мера. Для аналитики и уведомлений — как правило, избыточная.
Событие, пересекающее границу сервиса, — это публичный контракт. У него есть потребители, которых вы часто не контролируете и не всегда знаете. И как любой API, контракт события придётся менять: добавлять поля, переименовывать, дробить. Без формализованного управления схемами эволюция событий превращается в хаос — один производитель добавит поле, а десяток потребителей тихо упадёт при десериализации.
Schema Registry — централизованное хранилище схем событий (Confluent Schema Registry, Apicurio Registry, AWS Glue Schema Registry). Продюсер регистрирует схему, консьюмер по идентификатору в сообщении получает нужную версию.
Это даёт:
| Режим совместимости | Что разрешено | Когда использовать |
|---|---|---|
| Backward | Новый потребитель читает старые события. Добавление необязательных полей, удаление полей. | Потребители обновляются раньше продюсеров |
| Forward | Старый потребитель читает новые события. Добавление полей, удаление необязательных. | Продюсеры обновляются раньше потребителей |
| Full | Оба направления. Самый безопасный, но и самый ограничительный. | Независимый деплой продюсеров и консьюмеров |
| Backward Transitive | Совместимость со всеми предыдущими версиями, а не только с последней. | Event Sourcing, долгое хранение событий |
| Full Transitive | Оба направления + все предыдущие версии. | Максимальная безопасность, критичные системы |
Для систем с event sourcing и долгим хранением критически важны transitive-режимы: они гарантируют, что новая версия схемы совместима со всеми предыдущими. Без этого потребитель, читающий события годовой давности, может не справиться с десериализацией.
Consumer lag — это разница между последним опубликованным событием в партиции (log-end-offset) и последним обработанным событием потребителя (current-offset). Если lag растёт — потребитель не успевает. Если lag стабилен и близок к нулю — система здорова.
lag = log-end-offset - current-offset
Инструменты мониторинга:
Пороги зависят от сценария:
| Сценарий | Нормальный lag | Алерт |
|---|---|---|
| Real-time (инвентаризация, платежи) | 0–100 событий | >1000 или рост за 5 минут |
| Near-real-time (уведомления) | <1000 | >10000 или рост за 15 минут |
| Batch (аналитика, индексация) | <100000 | >1000000 или рост за час |
Рано или поздно потребитель получит событие, которое не может обработать: битая схема, логическая ошибка, отсутствующая сущность. Если такое событие блокирует партицию, весь поток встаёт. Решение — Dead Letter Queue (DLQ).
Как это работает:
Что важно в дизайне DLQ:
DLQ — это не свалка, а очередь ручного разбора. Если в вашей DLQ тысячи событий, и никто их не разбирает — считайте, что у вас нет DLQ, а есть только потерянные данные.
Событийно-управляемые микросервисы не живут в вакууме. Им нужна инфраструктура. Сегодня стандартом де-факто для упаковки и оркестрации этих сервисов являются контейнеры (Docker) и системы управления контейнерами (Kubernetes). Они позволяют сервисам масштабироваться, восстанавливаться после сбоев и изолироваться друг от друга.
Но контейнеризация — лишь верхушка айсберга. Полноценная EDM-инфраструктура включает:
Kafka / Redpanda / Pulsar — ядро системы, хранящее и доставляющее потоки.
Управление контрактами событий и их эволюцией.
Debezium, Kafka Connect — захват изменений из реляционных БД и трансляция их в события.
Kafka Streams, Apache Flink — оконные агрегации, joins между потоками, CEP.
OpenTelemetry, мониторинг lag, DLQ, алерты на аномалии в throughput.
Пайплайны, валидирующие совместимость схем через registry до деплоя.
Переход на EDM требует уплаты совокупности затрат — финансовых, временных, интеллектуальных — на поддержку всего вышеперечисленного. Малым командам этот налог может оказаться неподъёмным, и им лучше начать с модульного монолита, выделив границы bounded contexts внутри одного приложения и используя внутри-процессную шину событий.
Но для крупных организаций, где скорость и гибкость важнее простоты, этот налог — инвестиция в беспрецедентную масштабируемость.
Вторая глава закладывает техническую базу для всего дальнейшего путешествия. Но она также углубляет связь с первой главой — организационной.
В первой статье мы говорили о законе Конвея и о том, что архитектура отражает структуру команд. Теперь мы можем увидеть это на конкретном уровне:
| Организационный уровень (глава I) | Технический уровень (глава II) |
|---|---|
| Bounded Context (DDD) | Consumer Group — одна команда владеет одним потоком и его потребителями |
| Team Ownership | Topic Ownership — команда отвечает за схему, retention, партиционирование |
| Value Stream | Event Stream — поток фактов, отражающий бизнес-процесс |
| Team Topologies | Бизнес-топология — сеть pub-sub-подписок между сервисами |
| Reverse Conway Maneuver | Schema Registry + CI/CD — контракты как границы команд |
Микросервисы в EDM — это не просто изолированные куски кода, а узлы в графе потоков событий. Брокер событий — не просто «труба» для передачи данных, а надёжное хранилище истины, которое позволяет развязать сервисы во времени и пространстве.
Ключевые выводы главы:
Понимание этих механизмов — фундамент, без которого невозможно проектировать надёжные, масштабируемые и отказоустойчивые бизнес-топологии. В следующей главе мы перейдём от анатомии к физиологии: как эти строительные блоки объединяются в конкретные паттерны — Saga, Outbox, Event Sourcing — и как выбрать правильный паттерн под конкретную бизнес-задачу.
Глава II цикла «Событийно-управляемая архитектура». Источники: Confluent, Redpanda, Conduktor, Architecture Weekly, Microsoft Azure Architecture Center, практические кейсы production-систем.
If Chapter I answered the "why" — why businesses need event-driven microservices — now it is time to dig into the "how": how they are built under the hood. Event-Driven Architecture (EDA) is not just a message queue hidden behind a nice API. It is a fundamentally different paradigm of working with data, where every fact becomes an immutable part of history, and services act as independent observers and transformers of that history.
In the EDM world, the term "topology" is used to describe architecture at two completely different levels. Understanding the difference is critical for design.
Microservice topology is the "inner kitchen" of a single service. It describes how incoming events are filtered, transformed, enriched, and eventually published to outgoing streams. This is a graph of data-driven operations where nodes are standard transformations:
Discarding events not relevant to the service.
Transforming the event structure — adding computed fields, changing format.
Enriching an event with data from another stream or local state.
Folding multiple events into one — e.g., computing an order total.
Breaking one event into several — e.g., by order line items.
Grouping events by time window for analytics and pattern detection.
Business topology is the "city map." It shows how a multitude of independent microservices, event streams, and REST APIs interact to execute complex end-to-end business processes. Here, microservices are nodes and event streams are the roads between them.
The fundamental difference between EDA business topology and a regular REST call diagram lies in the data distribution model. In a REST network, every call is point-to-point: service A calls service B, and no one else knows about it. In an event topology, a single stream can be read simultaneously by dozens of independent consumers in pub-sub mode: notification service, analytics service, billing service, and search indexer — all subscribed to the same OrderEvents stream, none aware of the others.
Adding a new consumer requires neither changing the producer's code nor coordinating between teams. Instead of a rigid call graph, you get a flexible subscription network. This is what makes event topology fundamentally different.
An event is a statement of fact. Unlike classic messaging systems where a message is a command — "do something" — an event says: "here is what has already happened." Events are always named in the past tense (OrderPlaced, PaymentProcessed, CustomerRegistered), not in the imperative — this is not accidental but to emphasize their nature: the fact is already committed and cannot be undone.
In EDA practice, events are classified not by whether they carry a key but by semantics — how much information they carry and how consumers should work with them.
| Event Type | Payload | Pros | Cons |
|---|---|---|---|
| Domain Event | Full business fact: ID, context, all fields | Consumer processes without additional queries | Large volume, potential sensitive data leak |
| Event Notification | Lightweight signal: ID + what changed | Minimal data, privacy protection | Extra load on the source, thundering herd risk |
| Event-Carried State Transfer | Snapshot or delta of entity state | Maximum decoupling, consumer doesn't need the source | State duplication, volume |
Domain Events — significant business facts that domain experts care about. For example, OrderPlaced includes order ID, customer ID, line items, total, and timestamp. These are "rich" events carrying full context.
Event Notification Events — lightweight signals that only announce something happened, without details. For example, CustomerProfileUpdated may carry only the customer ID and the section that changed (ADDRESS), forcing the consumer to query the current state via API.
Event-Carried State Transfer Events — events that carry a snapshot or delta of entity state inside, allowing consumers to update their local projections without contacting the source. This type underpins materialized projections and CQRS.
Regardless of semantic type, an event usually carries a key — the identifier of the entity it relates to. The key determines which partition the event lands in and guarantees that all events for the same object or user are processed in strict order.
One of the most powerful concepts in EDM is the ability to materialize an event stream into a state table (simply by applying events in order) and, conversely, turn table changes back into an event stream. A stream is the history of changes; a table is the current state computed from that history. They are two forms of the same data, and the transition between them is a pure function.
The practical application of this duality is the CQRS pattern (Command Query Responsibility Segregation). A service subscribes to an event stream (e.g., OrderEvents), materializes a local projection table (read model), and serves it through a REST API. Write and read are separated: commands change state through events, and queries read from optimized projections.
This allows independent scaling of write and read sides and adaptation of the projection structure to specific queries — denormalize, index, cache.
Data is not just copied — it is filtered, aggregated, and recomputed for the needs of a specific consumer. For example, a recommendation service may build from the order stream a table "top-10 categories by purchase frequency for each customer" that does not exist in the source.
When an entity is deleted, a special event is sent to the stream — a tombstone (deletion marker). Consumers that materialized projections from this stream clear the corresponding records in their local stores upon receiving the tombstone. In Kafka, this is supported by log compaction: the broker keeps only the latest value for each key, deleting intermediate records and turning the stream into a compact snapshot of the current state.
Many developers habitually confuse event brokers (Apache Kafka, Redpanda) with classic message brokers (RabbitMQ, ActiveMQ). The difference is fundamental.
Works like a dispatcher: receives a message, waits for a consumer to pick it up, destroys it after acknowledgment. Push or pull model, but the message is one-shot.
Works like an immutable log of facts. Keeps every event forever (or per retention policy), letting new services replay history from any point.
Key requirements for a modern event broker:
Choosing a broker is not a religious question but an engineering one. Below is a comparison of the main players.
| Broker | Model | When to Choose | Limitations |
|---|---|---|---|
| Apache Kafka | Distributed log, pull model | Event streams, Event Sourcing, CQRS, analytics, millions of messages/sec | Complex operation, not for simple task queues |
| Redpanda | Kafka-compatible, C++ | Low latency, simpler operation, drop-in Kafka replacement | Smaller ecosystem, not all Kafka features 1:1 |
| Apache Pulsar | Log + queues, multi-tenancy | Multi-tenancy, geo-distribution, tiered storage | Fewer specialists, harder to tune |
| NATS JetStream | Lightweight streaming | Edge, IoT, low-latency microservices | Fewer guarantees and tools for heavy Event Sourcing |
| RabbitMQ | Classic broker, push model | Task queues, RPC, rule-based routing | Message is destroyed after ack — not for Event Sourcing |
| AWS EventBridge | Managed event bus | Serverless, AWS-native systems | Vendor lock-in, ordering and retention limits |
Rule of thumb: if you need a task queue — RabbitMQ or SQS. If you need a stream of facts — Kafka, Redpanda, Pulsar, or EventStoreDB. Mixing roles in one tool leads to a distributed big ball of mud.
An event broker provides data to consumers in two main ways, depending on the business task.
Each consumer manages its own "offset" — a pointer to the last event it read. Multiple independent consumer groups can read the same stream without interfering: each group maintains its own offset and moves at its own pace.
For example, an inventory service and a notification service can be two different consumer groups reading the same OrderEvents topic. The inventory service processes events synchronously and keeps its offset near the "tail" of the stream. The notification service may lag by hours under peak load — and that is fine, events are not lost, they just wait in the stream.
An event is processed by only one consumer instance from a pool. In Kafka, this is achieved via a consumer group: all consumers with the same group.id share the topic's partitions. If there are more consumers than partitions, the "extra" ones idle. If a consumer dies, its partitions are redistributed — this process is called rebalance.
Ordering may be violated across partitions, but fair load balancing is guaranteed. Ideal for background tasks, mailings, and integrations.
During an eager rebalance, all consumers in the group temporarily stop reading, give up partitions, and receive new ones. This downtime window can last seconds. Cooperative Sticky Assignor minimizes the problem by redistributing only the partitions that actually need to move — the rest do not stop. And static group membership (group.instance.id) lets a consumer get the same partitions back after restart without a full rebalance.
An offset is a consumer's position within a partition. Kafka does not track consumer progress automatically — that is the consumer's responsibility. The consumer periodically commits offsets, marking which events it has successfully processed.
| Strategy | Guarantee | Risk |
|---|---|---|
| Auto-commit (before processing) | At-most-once | Message loss on crash between commit and processing |
| Manual commit (after processing) | At-least-once | Duplicates on crash before commit |
| Transactional (Kafka EOS) | Exactly-once | Complexity, overhead |
During rebalance, a consumer that loses a partition before commit hands it to a new owner, who starts from the last committed offset — events already processed but not committed will be processed again. This is normal for at-least-once but means consumers must be idempotent.
In distributed systems, duplicates are not a bug but the norm. A consumer crashed before committing an offset, a rebalance happened, the message is processed again. A network failure triggered a producer retry, and the broker wrote the event twice. The result: the consumer will see the same event at least twice over the system's lifetime.
Deduplication protection is built on several levels:
enable.idempotence=true (default since Kafka 3.0).transactional.id, processing and offset commit happen atomically.Example of an idempotent consumer with a deduplication table:
BEGIN TRANSACTION;
IF EXISTS (SELECT 1 FROM processed_events WHERE event_id = :id) THEN
COMMIT;
RETURN;
END IF;
INSERT INTO processed_events (event_id, processed_at)
VALUES (:id, NOW());
UPDATE balances SET amount = amount + :delta
WHERE account_id = :account_id;
COMMIT;
Rule of thumb: at-least-once + idempotent consumer = effectively exactly-once. This is simpler and more reliable than transactional EOS for most scenarios.
Even with strict ordering within a partition, a consumer can encounter ordering violations at several levels:
Protection lies in embedding a sequence number in the event payload. The consumer compares the received number with the expected one and:
Expected 5, received 7. The consumer either waits for event 6 in a buffer or raises an alert and enters recovery mode.
Expected 7, received 5. The event has already been processed or lost. The consumer discards it to avoid rolling back state.
Sequence numbers can be:
For critical scenarios (financial transactions, inventory), this is a necessary measure. For analytics and notifications, it is usually overkill.
An event crossing a service boundary is a public contract. It has consumers you often do not control and do not always know. And like any API, the event contract will need to change: fields added, renamed, split. Without formalized schema management, event evolution turns into chaos — one producer adds a field, and a dozen consumers silently fail on deserialization.
Schema Registry is a centralized repository of event schemas (Confluent Schema Registry, Apicurio Registry, AWS Glue Schema Registry). The producer registers a schema, and the consumer gets the needed version by the ID in the message.
This provides:
| Compatibility Mode | What Is Allowed | When to Use |
|---|---|---|
| Backward | New consumer reads old events. Adding optional fields, removing fields. | Consumers upgrade before producers |
| Forward | Old consumer reads new events. Adding fields, removing optional fields. | Producers upgrade before consumers |
| Full | Both directions. Safest but most restrictive. | Independent deploy of producers and consumers |
| Backward Transitive | Compatibility with all previous versions, not just the latest. | Event Sourcing, long event retention |
| Full Transitive | Both directions + all previous versions. | Maximum safety, critical systems |
For systems with event sourcing and long retention, transitive modes are critical: they guarantee the new schema version is compatible with all previous ones. Without this, a consumer reading a year-old event may fail to deserialize it.
Consumer lag is the difference between the last published event in a partition (log-end-offset) and the last processed event of the consumer (current-offset). If lag grows — the consumer cannot keep up. If lag is stable and near zero — the system is healthy.
lag = log-end-offset - current-offset
Monitoring tools:
Thresholds depend on the scenario:
| Scenario | Normal Lag | Alert |
|---|---|---|
| Real-time (inventory, payments) | 0–100 events | >1000 or growing in 5 minutes |
| Near-real-time (notifications) | <1000 | >10000 or growing in 15 minutes |
| Batch (analytics, indexing) | <100000 | >1000000 or growing in an hour |
Sooner or later, a consumer will receive an event it cannot process: broken schema, logical error, missing entity. If such an event blocks the partition, the whole stream halts. The solution is a Dead Letter Queue (DLQ).
How it works:
What matters in DLQ design:
The DLQ is not a dump but a manual review queue. If your DLQ has thousands of events and no one reviews them — consider that you don't have a DLQ, only lost data.
Event-driven microservices do not live in a vacuum. They need infrastructure. Today, the de facto standard for packaging and orchestrating these services is containers (Docker) and container management systems (Kubernetes). They allow services to scale, recover from failures, and isolate from each other.
But containerization is only the tip of the iceberg. A full EDM infrastructure includes:
Kafka / Redpanda / Pulsar — the core of the system, storing and delivering streams.
Managing event contracts and their evolution.
Debezium, Kafka Connect — capturing changes from relational DBs and turning them into events.
Kafka Streams, Apache Flink — windowed aggregations, joins between streams, CEP.
OpenTelemetry, lag monitoring, DLQ, alerts on throughput anomalies.
Pipelines validating schema compatibility via registry before deploy.
The transition to EDM requires paying a combination of costs — financial, temporal, intellectual — for maintaining all of the above. For small teams, this tax may be unaffordable, and they are better off starting with a modular monolith, drawing bounded context boundaries inside a single application and using an in-process event bus.
But for large organizations where speed and flexibility matter more than simplicity, this tax is an investment in unprecedented scalability.
Chapter II lays the technical foundation for the rest of the journey. But it also deepens the connection with Chapter I — the organizational one.
In the first article, we discussed Conway's Law and how architecture mirrors team structure. Now we can see this at a concrete level:
| Organizational Level (Chapter I) | Technical Level (Chapter II) |
|---|---|
| Bounded Context (DDD) | Consumer Group — one team owns one stream and its consumers |
| Team Ownership | Topic Ownership — the team owns schema, retention, partitioning |
| Value Stream | Event Stream — a stream of facts reflecting a business process |
| Team Topologies | Business Topology — the pub-sub subscription network between services |
| Reverse Conway Maneuver | Schema Registry + CI/CD — contracts as team boundaries |
Microservices in EDM are not just isolated pieces of code but nodes in the event stream graph. The event broker is not just a "pipe" for data but a reliable store of truth that decouples services in time and space.
Key takeaways of the chapter:
Understanding these mechanisms is the foundation without which it is impossible to design reliable, scalable, and fault-tolerant business topologies. In the next chapter, we will move from anatomy to physiology: how these building blocks combine into specific patterns — Saga, Outbox, Event Sourcing — and how to choose the right pattern for a specific business task.
Chapter II of the "Event-Driven Architecture" series. Sources: Confluent, Redpanda, Conduktor, Architecture Weekly, Microsoft Azure Architecture Center, production system case studies.