SD System Design In Depth
0%
lesson system-designeventsoutbox

Event bus for product events

Module 4.2: Asynchronous Workflows & Partitioned Logs Updated Sep 17, 2026
📄 Cheat Sheet
The Many Meanings of Event-Driven Architecture • Martin Fowler • GOTO 2017
GOTO Conferences The Many Meanings of Event-Driven Architecture • Martin Fowler • GOTO 2017 Watch on YouTube ↗

Martin Fowler's GOTO talk separates event notification, event-carried state transfer, event sourcing and CQRS. That is the core design choice when broadcasting product events to many services.

0:00 / --:--

Under the Hood: The Enterprise Product Event Bus

In a microservices organization, business changes (e.g. OrderPlaced, UserSignedUp, SubscriptionCanceled) must be broadcast to dozens of downstream services (search indexing, email marketing, fraud detection, analytics, and accounting). An enterprise event bus coordinates these product events safely.

The Event-Driven Topology with Outbox & Schema Registry

flowchart TD App["Order Service"] -->|"1. Atomic ACID Transaction"| DB[("PostgreSQL: orders + outbox table")] DB -->|"2. Tail WAL Log (No Application Dual Write)"| Debezium["Debezium Change Data Capture (CDC)"] Debezium -->|"3. Validate Schema"| SchemaReg[("Confluent Schema Registry (Avro/Protobuf)")] Debezium -->|"4. Publish Event"| Kafka[("Kafka Topic: orders.events")] Kafka --> CG1["Search Index Worker"] Kafka --> CG2["Fraud Detection Worker"] Kafka --> CG3["Email Notification Worker"]

Under the Hood: Solving the Dual-Write Disaster

A fatal anti-pattern in distributed systems is writing to a database and publishing to a message broker in application code:

// THE DUAL-WRITE DISASTER: NEVER DO THIS
await db.orders.insert(order);
await kafka.publish("order_events", order); // What if this network call fails or server crashes?
      

If the Kafka write fails or the process crashes between lines, the database has the order but downstream workers never receive the event. If you reverse the order, the event is emitted but the database write could fail.

The Transactional Outbox Pattern: The application writes both the order record and an outbox event into the same local database transaction. A Change Data Capture (CDC) engine such as Debezium tails committed database changes and publishes them to Kafka. This closes the application dual-write window, but publication can be delayed or repeated, so consumers still need schema handling, idempotent effects, monitoring, and retention sufficient for recovery.

Worked example: an outbox row to three consumers

BEGIN;
INSERT INTO orders (id, user_id, total_cents, status)
  VALUES ('o_981', 'u_42', 4599, 'PLACED');
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
  VALUES ('evt_7f3', 'order', 'o_981', 'OrderPlaced',
          '{"orderId":"o_981","userId":"u_42","totalCents":4599,"schemaVersion":3}');
COMMIT;

Debezium reads the committed outbox insert from the Postgres WAL and routes it to topic order.events, keyed by o_981 so that all events for this order stay in one partition, in order. Search, fraud and email each consume in their own consumer group. If Debezium crashes after publishing but before recording its WAL position, it republishes evt_7f3 on restart. Consumers deduplicate on the event ID.

Event styles (Fowler's taxonomy)

StylePayloadConsumer couplingTrade-off
Event notificationID plus type onlyConsumers call back the producer's APISmall events, but a callback load spike on the producer
Event-carried state transferFull relevant stateConsumers keep local copiesNo callbacks, but larger events and schema weight
Event sourcingEvery state change is the source of truthRebuild state by replayFull audit trail, but hard schema evolution

Schema evolution rules (Avro or Protobuf with a registry)

  • Backward compatible (new consumers read old events): you may delete fields, or add fields that have defaults.
  • Forward compatible (old consumers read new events): you may add fields, or delete fields that have defaults.
  • Never change a field's type or reuse a Protobuf field number.
  • Include an envelope: event ID, type, source, occurrence time, schema version, and optionally a trace ID. CloudEvents standardises these attributes.

Failure modes

  • Outbox table growth: delete or partition rows once they are published, or the CDC-read table bloats.
  • WAL retention: a stopped Debezium replication slot makes Postgres keep WAL indefinitely, which can fill the disk. Alert on slot lag.
  • Event storms: a backfill publishes 50 million events and swamps consumers. Throttle backfills, or give them a separate topic.
Check yourself Question 1 of 3
Dual-write crash recall
From: Under the Hood: Solving the Dual-Write Disaster

An order service commits the order row and then publishes OrderPlaced to Kafka in application code. The process crashes between the two steps. What is the outcome, and what is the fix?

Key Takeaways

A transactional outbox commits domain state and the publication obligation together, removing the application dual-write race; CDC retries and consumer effects still require recovery and deduplication.
Schema registries can enforce backward and forward compatibility for Avro or Protocol Buffers schemas, but only when producers register and validate schemas through the registry and a compatibility mode is configured for the subject.
Use a documented event envelope with stable identity, type, producer, schema version, and occurrence time; CloudEvents is one optional standard.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue