System DesignAdvanced 14 min Lesson 32 of 42

Event-Driven Architecture

Components announce facts instead of calling each other, which decouples them — and makes the flow harder to follow.

System Design · Lesson 32 of 42
0/42 done(0%)

What is it? #

In an event-driven architecture, components publish events describing what happened, and other components react.

An event is a statement of fact in the past tense: OrderPlaced, PaymentFailed, UserRegistered. It is not an instruction. The publisher does not know or care who consumes it.

That inversion is the point. Adding a new reaction means adding a consumer, with no change to the publisher.

The cost is that the flow of a business process is no longer visible in any single place, and consistency becomes eventual rather than immediate.

Think of it like this #

A public announcement board rather than individual phone calls. The dispatcher posts "order 123 shipped" and whoever cares — billing, the customer app, the analytics team — reads it.

Nobody has to maintain a list of who to phone. The trade is that no single person can tell you everything that happens after an order ships.

Simple example #

When an order is placed, four things must follow: a confirmation email, a warehouse notification, an analytics event and a loyalty update. Direct calls would couple ordering to all four.

Code #

PYTHON
# Events are facts, named in the past tense, with a stable shape
ORDER_PLACED = {
    "event": "order.placed",
    "version": 1,                              # version the schema from day one
    "id": "evt_01H9...",                       # unique, for deduplication
    "occurred_at": "2026-09-22T10:15:03Z",
    "data": {
        "order_id": "A-1",
        "customer_id": 7,
        "total": 2499.0,
        "currency": "INR",
    },
}
PYTHON
# Publisher: writes the event in the SAME transaction as the state change
def place_order(cart, user):
    with db.transaction():
        order = orders.insert(user_id=user.id, lines=cart.lines, total=cart.total)
        outbox.insert(                            # transactional outbox
            event="order.placed",
            payload={"order_id": order.id, "customer_id": user.id, "total": order.total},
        )
    return order
    # A separate process reads the outbox and publishes to the broker.
    # This guarantees the event exists if and only if the order does.


# Consumer: independent, idempotent, and free to fail without affecting others
def on_order_placed(event):
    if processed.seen(event["id"]):               # deduplicate
        return
    send_confirmation_email(event["data"]["order_id"])
    processed.record(event["id"])
TEXT
Events vs commands

command   "ChargePayment"   an instruction to one specific handler, may be rejected
event     "PaymentCharged"  a fact that already happened, for anyone interested

Mixing them up produces events that are really instructions, which
recreates the coupling you were trying to remove.
TEXT
What this costs you

flow visibility        no single file shows what happens after an order
eventual consistency   consumers lag; the read model may be briefly stale
ordering               events may arrive out of order across partitions
duplicates             at-least-once delivery means consumers must be idempotent
schema evolution       old consumers must tolerate new fields; version events
debugging              you need tracing and an event log to reconstruct a flow

How it works #

Events describe facts, which is why the past tense matters. OrderPlaced cannot be refused; PlaceOrder can. Naming them as facts keeps the publisher free of assumptions about consumers.

The transactional outbox solves the hardest reliability problem in this style. Writing to the database and publishing to a broker are two systems, so one can succeed while the other fails. Writing the event into an outbox table inside the same transaction makes them atomic, and a separate process publishes from the outbox afterwards.

Consumers must be idempotent, because brokers deliver at least once. Recording processed event IDs is the standard defence.

Consumers are independent. If the loyalty consumer is broken, emails still go out, and the loyalty events can be replayed once it is fixed.

Versioning events from the first day is worth the small effort. Consumers deploy at different times, so an event shape change without versioning breaks whichever consumer updates last.

Event sourcing is a further step, where the event log becomes the source of truth and current state is derived by replaying it. It gives a complete audit trail and time travel, at the cost of significant complexity — it is a separate decision from simply using events.

Real-world use #

Kafka, Pulsar, cloud event buses and durable queues are the usual infrastructure. Kafka in particular keeps a retained log, so consumers can replay history — which is what makes adding a new consumer over old data possible.

Analytics and data pipelines are the most common entry point: publishing domain events and letting a warehouse consume them avoids querying production databases for reporting.

Eventual consistency has to be designed into the product, not hidden. A user who places an order and sees "processing" rather than an instant confirmation is fine; one who sees their order missing is not.

The debugging burden is real. Without distributed tracing and a searchable event log, reconstructing why something did not happen becomes archaeology.

Many systems use events selectively: synchronous calls for the essential path, events for the follow-ups. That combination keeps the critical flow visible while decoupling everything optional.

Common mistakes #

  • Publishing an event after the transaction commits, so a crash loses it — use an outbox.
  • Events that are really commands, which recreates tight coupling.
  • Consumers that are not idempotent, so duplicates cause double effects.
  • No versioning, so a schema change breaks consumers that deploy later.
  • Using events for a flow that needs an immediate, consistent answer.

Practice #

Design the events for a subscription service: signup, payment succeeded, payment failed, subscription cancelled. Specify the payload and version for each, name two consumers per event, and describe how you would guarantee the event is published exactly when the state change happens.

Quick quiz

  1. 1. How should events be named?

  2. 2. What problem does a transactional outbox solve?

  3. 3. Why must consumers be idempotent?

  4. 4. What is the main cost of event-driven design?

  5. 5. What is event sourcing?

Summary

  • Components publish facts; consumers react independently.
  • Use an outbox so the event and the state change are atomic.
  • Assume duplicates and out-of-order delivery; make consumers idempotent.
  • Version events from the beginning.
  • Use events for follow-ups and keep the essential path synchronous.