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.

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 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).

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.

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?

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.

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.