Event-Driven Architecture

Software Architecture Fundamentals

Chapter 6 · Event-Driven Architecture

Chapter 5 ended with two verified boundary functions — send_order_confirmation_email and update_user_loyalty_points — that both needed direct knowledge of both the Order and User domains to work. This chapter rebuilds them without that direct knowledge, using the exact mechanism Design Patterns Chapter 8 already gave you: publish/subscribe.

The Event Bus: Observer, Generalized for Typed Events

class EventBus: def __init__(self): self._subscribers = {} def subscribe(self, event_type, handler): self._subscribers.setdefault(event_type, []).append(handler) def publish(self, event_type, payload): for handler in self._subscribers.get(event_type, []): handler(payload) class OrderService: def __init__(self, event_bus): self.event_bus = event_bus; self.orders = {} def place_order(self, order_id, items_total, user_id): self.orders[order_id] = {'items_total': items_total, 'user_id': user_id} self.event_bus.publish('OrderPlaced', {'order_id': order_id, 'items_total': items_total, 'user_id': user_id})

UserService and EmailService subscribe to 'OrderPlaced' independently — OrderService never references either one by name:

Verified directly — OrderService's own source contains zero reference to either subscriber
Scanning OrderService's source via inspect.getsource(): the string 'UserService' appears False times, and 'EmailService' appears False times. Calling order_service.place_order('ORD-1', 100, 'USER-1') correctly awarded 10 loyalty points (100 // 10) to USER-1 and correctly queued the confirmation email — both through subscriptions OrderService has no knowledge of.
Verified directly — a third subscriber requires zero changes to OrderService
Adding AnalyticsService.log_order as a new subscriber (via one bus.subscribe('OrderPlaced', analytics_service.log_order) call) and placing a second order correctly logged it — while OrderService's own code was never touched. This is the exact same "add an observer, zero changes to the subject" finding Design Patterns Chapter 8 verified for Stock, now applied one level up: to services instead of objects.

Comparing Chapter 5's Direct Version Against This Chapter's Event-Driven Version

Chapter 5 — direct callsChapter 6 — pub/sub events
Does OrderService know UserService exists?Yes — send_order_confirmation_email calls get_user_profile directlyNo — verified: zero source references
Adding a new reaction to "order placed"Edit an existing function to add a new callAdd a new subscriber — verified: zero OrderService changes
What happens if a reaction is slow or fails?The whole call chain waits or fails together (Chapter 4's cascading-call finding)Isolated per subscriber — covered further in this course's own resilience-pattern chapter

A Basic Event-Sourcing Example

Instead of only reacting to events as they happen, an EventLog can record every event permanently — making the events themselves the source of truth, not just a running total.

class EventLog: def __init__(self): self.events = [] def append(self, event_type, payload): self.events.append((event_type, payload)) def replay_total_revenue(event_log): total = 0 for event_type, payload in event_log.events: if event_type == 'OrderPlaced': total += payload['items_total'] return total
Verified directly — exact totals reconstructed purely by replaying the log, no separate running counter needed
After publishing three OrderPlaced events (100, 50, 75) through an EventBus that logs every event before dispatching it, replay_total_revenue(log) returns 225, matching a manual sum (100+50+75) exactly. Filtering the same log for USER-1's own events reconstructs their order count (2) and total spend (150) — every one of these numbers comes entirely from replaying the stored events, not from a separately-maintained total that could drift out of sync.

The Eventual Consistency Tradeoff, Measured

Chapter 4's cascading-call demo showed a synchronous chain making a caller wait for every downstream step. An asynchronous event bus avoids that wait — by genuinely not having the update happen yet.

class AsyncEventBus: def __init__(self): self._subscribers = {}; self._queue = [] def subscribe(self, event_type, handler): self._subscribers.setdefault(event_type, []).append(handler) def publish(self, event_type, payload): self._queue.append((event_type, payload)) # queued — NOT processed immediately def process_queue(self): # simulates a background worker running later for event_type, payload in self._queue: for handler in self._subscribers.get(event_type, []): handler(payload) self._queue.clear()
Verified directly — a real, measurable window where the order exists but its effects don't yet
Calling order_service.place_order('ORD-1', 100, 'USER-1') against an AsyncEventBus returns successfully, and order_service.orders correctly shows the new order. But checking user_service.users immediately afterward shows {} — genuinely empty. Only after bus.process_queue() runs does user_service.users correctly show {'USER-1': {'points': 10}}. Between those two points, the system is honestly, verifiably inconsistent — the order is real, but the loyalty points aren't there yet.
This is the actual price of the decoupling verified earlier in this chapter
Chapter 5's direct version was fully consistent the instant send_order_confirmation_email/update_user_loyalty_points returned — but only by giving OrderService direct knowledge of both other domains. This chapter's event-driven version genuinely doesn't know or care who's listening, or when they'll get around to processing the event — and that's precisely what creates the gap just measured. Neither tradeoff is free; which one to accept depends on whether "the user sees their points immediately" or "OrderService never has to know UserService exists" matters more for a given system.

Where This Connects

This chapter's findingWhat it connects to
The event bus verified as Observer, reused a second time (after MVVM in Chapter 3)Confirms this course's own recurring theme — design-level patterns are the concrete building blocks architecture-level decisions are actually made of
A real, measured eventual-consistency windowDistributed Systems & Scalability's own CAP Theorem & Consistency Models chapter — this chapter's own small demo is a concrete instance of that larger tradeoff
Event-sourcing reconstructing exact totals purely from a logTechnical Support's own `log1`/`backup1` — an event log serving the same "the record is the source of truth" role a well-kept audit log serves there

Hands-On Exercises

Exercise 1

Add a fourth subscriber, InventoryService.reserve_stock, to this chapter's own synchronous EventBus example, subscribed to 'OrderPlaced'. Verify it fires correctly alongside the existing three subscribers, and confirm OrderService's own source still contains zero reference to it.

📄 View solution
Exercise 2

Using this chapter's own EventLog, write a replay_orders_over(event_log, threshold) function that reconstructs — purely by replaying the log, no running counter — the count of orders with items_total greater than threshold. Verify it against this chapter's own three-event log (100, 50, 75) with a threshold of 60.

📄 View solution
Exercise 3

Using this chapter's own verified eventual-consistency finding, explain what a user-facing "Order placed! Your loyalty points will update shortly" message is actually doing — and why Chapter 5's original direct-call version would never have needed a message like that at all.

📄 View solution

Chapter 6 Quick Reference

  • Event bus: publishers publish typed events with no knowledge of subscribers — verified: OrderService's own source contained zero reference to either subscriber, and a third subscriber was added with zero OrderService changes
  • Event sourcing: the event log itself is the source of truth — verified: exact revenue (225) and per-user totals reconstructed purely by replaying stored events
  • Eventual consistency, measured: a real, verified window existed where order_service.orders reflected the new order but user_service.users was still empty, closed only once the queued event was processed
  • Next chapter: Hexagonal / Clean Architecture — pushing the same dependency-direction discipline even further