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.| You are modelling | It is a… | Because |
|---|---|---|
OrderConfirmed | event | Past tense, many consumers, safely ignorable. |
SendReceipt | command | One handler, must happen, has an addressee. |
OrderUpdated | neither, yet | “Updated” names no fact — what changed? |
| a handler that raises to stop the producer | command in event clothing | If 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
- "What Do You Mean by Event-Driven?", Fowler — event notification vs event sourcing vs CQRS: the vocabulary.
- Architecture Patterns with Python, Percival & Gregory — ch. 8–10: the message bus in working Python, events as domain objects.
- "Event Sourcing", Fowler — when events become the storage, not just the communication.
- Watch: ArjanCodes YouTube — search "event driven architecture".
- Next: lesson 0026 — libraries vs building.
- Reference: Patterns — message bus and unit of work, the transaction the publish must wait for.
Map your workflow's reaction chain with your agent-teacher before converting it to events.