Software Design Reference System Designer · Designing Systems That Scale

Lesson 0025 · System Designer · Module 3

Event-Driven Architecture

When one event sets off many reactions, direct calls chain every component to every other. Events break the chain: the producer doesn't know its consumers exist.

Reading ≈ 9 minPractice ≈ 45 min

Mission tie-in: "use event-driven architecture to decouple workflows and components" — the System Designer's last module, and the stepping stone to the trade-offs of Phase III.

Knowledge: facts, not instructions

An event is a fact that happened, named in the past tense: OrderConfirmed, PaymentReceived, StockDepleted. It is an immutable value object (lesson 0012) carrying what happened, not what to do. Consumers decide their own reaction.

@dataclass(frozen=True)
class OrderConfirmed:
    order_id: str
    occurred_at: datetime

# the bus: routes events to handlers
class MessageBus:
    def __init__(self) -> None:
        self._handlers: dict[type, list[Callable]] = {}

    def register(self, event_type: type, handler: Callable) -> None:
        self._handlers.setdefault(event_type, []).append(handler)

    def publish(self, event: object) -> None:
        for handler in self._handlers.get(type(event), []):
            handler(event)

# the domain records the fact; it does not publish it:
class Order:
    def __init__(self, order_id: str) -> None:
        self.order_id = order_id
        self._status = Status.PENDING
        self.events: list[object] = []       # facts, collected

    def confirm(self) -> None:
        if self._status != Status.PENDING:
            raise OrderLockedError()
        self._status = Status.CONFIRMED
        self.events.append(OrderConfirmed(self.order_id, now()))

# the service publishes only after the write succeeded:
def confirm_order(order_id: str, orders: OrderRepository, bus: MessageBus) -> None:
    order = orders.get(order_id)
    order.confirm()
    orders.save(order)                       # commit first…
    for event in order.events:               # …then announce
        bus.publish(event)

# the wiring — handlers care, the Order doesn't:
bus.register(OrderConfirmed, send_confirmation_email)
bus.register(OrderConfirmed, issue_invoice)
bus.register(OrderConfirmed, reserve_stock)
The principle behind the principle

An event inverts who knows about whom. In a direct call the producer names its consumers, so adding a consumer edits the producer. With an event the consumer names the producer, so adding a consumer edits nothing that already works. That inversion is the only thing events buy, and it is bought in one specific currency: the call stack. You are trading static traceability — I can read what happens next — for modification locality — I can add what happens next without touching what exists.

Stated that way the trade tells you its own conditions. It pays when consumers are many, optional, and genuinely independent, and when the producer must not care whether they succeed. It stops paying the moment the producer needs the result (that is a function call), exactly one consumer must handle it (that is a command), ordering between consumers matters (no bus promises it), or there is one consumer and there always will be (a direct call with ceremony and a lost traceback).

There is a second boundary that gets crossed by accident. An in-process synchronous bus keeps your transaction, your ordering, and your stack; an out-of-process broker gives isolation and takes all three, and hands you at-least-once delivery in exchange, which means every handler must become idempotent. Most teams adopt the vocabulary of the second while running the first, and discover the difference during the migration.

The two-step — collect in the domain, publish after the commit — is not ceremony. Publish inside confirm() and a failed save leaves three handlers acting on an order that does not exist: an invoice for nothing, an email about nothing, stock reserved for nothing. The domain records what happened; the layer that owns the transaction decides when it is true enough to announce.

Order knows nothing about email, invoicing, or stock — it announced a fact, and the bus did the rest. Adding a fourth reaction (analytics) touches the wiring only. That's the decoupling: producer ignorance.

The honest price: the flow is no longer visible in one call stack. Reading the code tells you what happened, not what will happen next — that knowledge lives in the wiring. Event-driven is a trade, not a free lunch (the full accounting comes in Phase III's sync vs async).

Worked example: the handler that stopped the two after it

An order service publishes OrderConfirmed to four in-process handlers: email, invoice, stock reservation, analytics. At about 6,000 orders a day it runs quietly for a year. Then the invoicing handler starts raising on a malformed partner VAT number — and because publish is a plain loop, handlers three and four never run for those orders.

The first thing a senior designer inspects is not the invoice bug. It is the failure semantics of the publish loop: what happens to handler N+1 when handler N raises? That is a property of the bus, and nobody had ever written it down. The bug in the VAT parser is a week's worth of incident; the unwritten semantics are the thing that will produce the next three incidents.

The second question is the one that reframes the design: "is each of these reactions genuinely allowed to fail, and who finds out when it does?" Asked plainly, three of the four answer "yes, and nobody needs to know today". Stock reservation answers "no — if this doesn't happen we oversell". That answer means stock reservation was never an event reaction at all. It is a step in the workflow that happens to have been wired like a notification.

The rejected repair is to wrap each handler in try/except and log. It fixes the ordering bug and leaves the real one untouched: the stock is still unreserved, and now silently so. Count the damage before choosing. Malformed VAT numbers appeared on 1.2% of orders for three days — roughly 216 orders shipped without a reservation. Roughly one in eight became an actual oversell: about 27 items, each costing an apology, an expedited restock and a support contact at something like €40 all-in, so a little over €1,000 in direct cost and a much larger dent in the "we have it in stock" promise. The try/except would have prevented none of it.

So two changes ship, and only one of them is the bus. Stock reservation moves out of the event chain into the confirmation workflow, where its failure fails the operation. The remaining three handlers get isolated, with failures preserved for replay rather than logged and lost:

class MessageBus:
    def __init__(self, dead_letters: DeadLetterQueue) -> None:
        self._handlers: dict[type, list[Callable]] = {}
        self._dead_letters = dead_letters

    def publish(self, event: object) -> None:
        for handler in self._handlers.get(type(event), []):
            try:
                handler(event)
            except Exception as failure:      # one handler, one blast radius
                self._dead_letters.put(event, handler.__name__, failure)

That except Exception looks like a violation of lesson 0021 and is not. Lesson 0021's rule is "catch only what you can handle" — and this code can handle it, because its job is isolation: it changes the outcome by preserving the event for replay instead of discarding it, and it is the one layer that knows the blast radius is meant to stop here. A catch that changes the outcome is handling; a catch that only continues is not.

† Events vs commands: events are past-tense facts (may have many consumers, can be ignored); commands are requests (exactly one consumer, must be handled). Mixing them on one bus is how event systems develop confused semantics.
Field notes · event or command?
You are modellingIt is a…Because
OrderConfirmedeventPast tense, many consumers, safely ignorable.
SendReceiptcommandOne handler, must happen, has an addressee.
OrderUpdatedneither, yet“Updated” names no fact — what changed?
a handler that raises to stop the producercommand in event clothingIf the producer cares about the outcome, it was never an event.
What people get wrong after reading this
"Events decouple, so more events means less coupling."
Events move coupling from the call graph to the schema. Every consumer depends on the event's fields, so renaming one breaks four consumers at once — silently, at runtime, instead of loudly, at import.
"An in-process bus is a stepping stone to a broker."
The vocabulary transfers; the semantics do not. In-process is ordered, exactly-once and inside your transaction. A broker is unordered, at-least-once and outside it. Handlers written against the first are wrong against the second, and the compiler will not say so.
"Publishing inside the aggregate is safe — the ORM will roll back."
The ORM rolls back your database. It does not un-send an email, un-charge a card, or un-reserve stock in a partner's system. That is the entire reason for collect-then-publish-after-commit.
"Event-driven means asynchronous."
A synchronous in-process bus is event-driven and keeps the stack. Whether the reaction crosses a process boundary is a separate decision with its own trade-offs (lesson 0031) — deciding both at once is how teams buy queue infrastructure to solve a naming problem.

When this is the wrong move

Don't reach for a bus when there is one consumer, when the producer needs the result, when the order of reactions matters, or when a reaction must happen — that last one is a command, and dressing it as an event is exactly the misclassification the worked example cost money on. Be honest about the small-team case too: in a service where "who reacts to this?" is answered by one grep, indirection has a real read-tax and no offsetting benefit. Events earn their keep when the set of consumers changes more often than the producer does.

Skill: what's the decoupling?

An event is best described as:

The producer of OrderConfirmed knows:

The honest cost of the bus is:

Practice on your own code

Find a workflow where one event triggers three or more reactions (a save that emails, logs, and syncs). Extract the fact as an event value object, emit it from the domain, and register the three reactions in the wiring. Then answer honestly: is the flow easier or harder to follow?

In the lab. The phase-2 checkpoint has the returns-resolution service sending the customer email from inside the domain method, before the save. You collect the fact on the aggregate, publish after the commit, and then decide which of the four reactions is actually a command that must not be allowed to fail quietly.

Reveal: a workflow extraction

A save_article function emailed editors, invalidated a cache, and enqueued a search reindex — three side effects, one call stack. Extracted: ArticlePublished emitted by Article.publish(); three handlers registered in build_app(). Adding "notify subscribers" is now one line of wiring, and the domain gained a published-at fact worth having.

Teach it back
  1. Explain to a junior, in two sentences, what an event buys and what it costs — using the words "call stack".
  2. Find one place in your own code this week where a side effect happens before the commit. Name its cost: what has already reached the outside world when the transaction rolls back?
  3. Predict what breaks in a year if a handler is added for every new requirement: how does somebody answer "what happens when an order is confirmed?"

Your win

You can spot the multi-reaction workflows, extract facts as events, and wire reactions at the composition root — while naming the visibility cost honestly.

Read and watch deeper

Map your workflow's reaction chain with your agent-teacher before converting it to events.