β‘ Event-Driven Architecture
In a request-driven system, services command each other: "Payment service, charge this card." In an event-driven system, a service simply announces a fact β "OrderPlaced" β and any number of others react, without the announcer knowing who's listening. That single inversion produces systems that are looser, more resilient, and far easier to extend.
Week 14 · Tuesday: Message Queues · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define an event as an immutable, past-tense fact and design well-named events
- Explain how pub/sub over a topic exchange underpins event-driven systems
- Distinguish choreography from orchestration and pick the right one
- Contrast event notification with event-carried state transfer
- Describe event sourcing and reason about eventual consistency
- Wire up a small multi-service e-commerce flow with Node.js and RabbitMQ
Estimated Time: 70 minutes
Practice: Run an event storm for a domain and trace an order through a choreographed service graph.
In This Lesson
From Requests to Events
Consider what happens when a customer places an order. In a request-driven design, the order service must call payment, then inventory, then notifications β and know each of their addresses, contracts, and failure modes. Add a fraud-check service next quarter and you're editing the order service again. The order service becomes a spider at the center of an ever-growing web.
In the event-driven design, the order service publishes a single OrderPlaced fact and is done. Payment, inventory, and notifications each subscribe. Adding fraud-check means adding a new subscriber β zero changes to the order service. The producer is blissfully ignorant of its consumers, and that ignorance is the whole point.
π° Analogy: the newspaper
Reporters (producers) publish stories without knowing who reads them. The paper (event channel) distributes to everyone. Readers (consumers) each react differently β one clips a coupon, another reads the sports page. And yesterday's edition is a permanent record, exactly like an immutable event log.
What Is an Event?
An event is a record of something that already happened. It is immutable β you can't un-happen the past β and it's named in the past tense to reflect that.
| β Event (a fact) | β Command (an instruction) |
|---|---|
OrderPlaced | PlaceOrder |
PaymentProcessed | ProcessPayment |
UserRegistered | RegisterUser |
The naming isn't pedantry. A command is addressed to one handler expected to obey; an event is broadcast to anyone who cares, expected of no one in particular. Past-tense names keep you honest that you're describing history, not issuing orders.
A well-formed event
{
"eventId": "e7f45ce3-2c3b-4af4-8ce0-843a3e58a3f9", // unique β enables dedupe
"eventType": "OrderPlaced", // past tense
"timestamp": "2026-07-31T14:30:00Z", // when it happened
"producer": "order-service", // who emitted it
"version": "1.0", // schema version
"correlationId": "c9b5f789-3a5b-46f3-8dd4-40f12d41f202", // trace across services
"data": { // the payload (the fact)
"orderId": "ORD-12345",
"customerId": "CUST-6789",
"items": [
{ "productId": "PROD-101", "quantity": 2, "price": 25.99 }
],
"totalAmount": 51.98
}
}
π‘ The metadata earns its keep
The eventId lets consumers dedupe under at-least-once delivery. The correlationId lets you trace one customer action across a dozen services in your logs. The version lets schemas evolve without breaking old consumers. Skimp on these and debugging distributed flows becomes guesswork.
Pub/Sub Is the Engine
Event-driven architecture is publish/subscribe wearing a design pattern. From the last lesson you already have the mechanism: a topic exchange. Producers publish events with a dotted routing key like order.placed; each service binds a queue with a pattern for the events it wants.
Analytics binds order.# to capture every order event; payment binds only order.placed. Each subscriber gets its own queue, so a slow analytics consumer never holds up payment. This is the crucial difference from a work queue: in pub/sub, every interested service gets its own copy, and each can fail, retry, and scale independently.
β One event, many independent reactions
Because each service owns its queue and its offset through the work, you can take analytics offline for maintenance and its backlog simply waits β while payment and notifications carry on untouched. Independent queues are what turn pub/sub into genuine resilience.
Notification vs State Transfer
How much data should an event carry? Two ends of a spectrum, with a pragmatic middle.
Event notification β "something happened, go look"
The event carries only an identifier. Consumers call back to the source for details.
Pro: tiny messages, always-fresh data. Con: reintroduces coupling β consumers must call back, and the source becomes a dependency again.
Event-carried state transfer β "here's everything you need"
The event carries the full relevant state, so consumers never call back.
Pro: maximal decoupling, no callbacks, consumers work even if the source is down. Con: bigger messages, and the data is a snapshot that can go stale.
π‘ The hybrid usually wins
Carry the essential fields consumers almost always need, plus identifiers for the rest. An OrderPlaced event might include the order total and item ids (used by nearly everyone) but reference the full customer profile by id (needed by few). Optimize for your actual consumers, not for purity.
Choreography vs Orchestration
When a business process spans several services, how do you coordinate the steps? Two philosophies.
| Choreography | Orchestration | |
|---|---|---|
| Control | Decentralized β each service reacts to events and emits its own | Centralized β one coordinator directs each step |
| Coupling | Loose; services don't know the overall flow | Tighter; the orchestrator knows every step |
| Visibility | Flow is emergent β harder to see end to end | Flow lives in one place β easy to read |
| Best for | Simple, additive flows; maximum autonomy | Complex flows needing clear control and error handling |
Choreography: services dance to the music
No conductor β each service listens for the event before it and emits the event after. Beautifully decoupled, but the end-to-end process exists only as an emergent property of who-listens-to-what, which can be hard to trace.
Orchestration: a conductor leads
A central orchestrator drives each step and handles failures with compensating actions β the Saga pattern. If inventory can't be reserved after payment succeeds, the orchestrator issues a refund. The trade is clarity for a touch more coupling.
β οΈ Distributed transactions don't exist here
You cannot wrap four services in one ACID transaction. Instead you accept partial progress and design compensating steps that undo it: refund a payment, release reserved stock. This is the Saga pattern, and it's how event-driven systems stay consistent without a global lock.
Event Sourcing & Consistency
Event sourcing flips how you store data. Instead of saving the current state and overwriting it on each change, you store the full sequence of events that produced it. Current state is derived by replaying them.
append-only"] ES -->|replay| ST["Current State"] ES -->|replay| AU["Audit Trail"] ES -->|replay| AN["Analytics"]
// A bank account, sourced from events instead of a mutable balance.
const events = [
{ type: 'AccountOpened', data: { balance: 0 } },
{ type: 'MoneyDeposited', data: { amount: 100 } },
{ type: 'MoneyWithdrawn', data: { amount: 30 } },
];
// Replay (a "projection") to compute current state.
const balance = events.reduce((total, e) => {
if (e.type === 'MoneyDeposited') return total + e.data.amount;
if (e.type === 'MoneyWithdrawn') return total - e.data.amount;
return total;
}, 0);
console.log(balance); // 70 β never stored, always derived
The payoff: a perfect audit trail, the ability to rebuild state at any past moment ("time travel"), and new projections built from history without migrating data. The cost: more moving parts, and queries need pre-built read models. It's a powerful tool β reach for it when auditability and history genuinely matter, not by default.
Eventual consistency
Because reactions happen asynchronously, there's a window where the system is internally inconsistent: the order says PAID but inventory hasn't reserved stock yet. Moments later, it catches up. This is eventual consistency β and it's the fundamental trade you accept for decoupling and scale.
π‘ Design the UI for the gap
Show "Order received, processing paymentβ¦" rather than pretending everything is instantly done. Users accept a brief, honest delay far more readily than they forgive a page that claims success and is later contradicted. Embrace the window; don't hide it.
Building It: Node.js + RabbitMQ
Let's turn theory into a small e-commerce system. Every service shares one thin RabbitMQ client that publishes to and subscribes on a single topic exchange named events.
The shared client
// common/rabbit-client.js
const amqp = require('amqplib');
const { randomUUID } = require('crypto'); // built-in, no dependency needed
class RabbitClient {
constructor() {
this.channel = null;
this.EXCHANGE = 'events';
}
async connect() {
const connection = await amqp.connect('amqp://localhost');
this.channel = await connection.createChannel();
await this.channel.assertExchange(this.EXCHANGE, 'topic', { durable: true });
console.log('Connected to RabbitMQ');
return this.channel;
}
// Publish an event as a fact. routingKey is derived from the type.
async publishEvent(eventType, data) {
const event = {
eventId: randomUUID(),
eventType,
timestamp: new Date().toISOString(),
producer: process.env.SERVICE_NAME || 'unknown',
version: '1.0',
correlationId: data.correlationId || randomUUID(),
data,
};
const routingKey = toRoutingKey(eventType); // 'OrderPlaced' -> 'order.placed'
this.channel.publish(
this.EXCHANGE, routingKey,
Buffer.from(JSON.stringify(event)),
{ persistent: true }
);
console.log(`Published ${eventType} (${routingKey})`);
}
// Each service gets its OWN durable queue per event type it cares about.
async subscribe(eventType, handler) {
const service = process.env.SERVICE_NAME || 'unknown';
const queue = `${service}.${toRoutingKey(eventType)}`;
await this.channel.assertQueue(queue, { durable: true });
await this.channel.bindQueue(queue, this.EXCHANGE, toRoutingKey(eventType));
this.channel.consume(queue, async (msg) => {
if (!msg) return;
try {
await handler(JSON.parse(msg.content.toString()));
this.channel.ack(msg); // ack only after the handler succeeds
} catch (err) {
console.error(`Handler for ${eventType} failed:`, err);
this.channel.nack(msg, false, false); // send to DLQ, don't loop forever
}
}, { noAck: false });
console.log(`Subscribed to ${eventType} on queue ${queue}`);
}
}
// 'OrderPlaced' -> 'order.placed' (topic-friendly routing key)
function toRoutingKey(eventType) {
return eventType.replace(/([a-z])([A-Z])/g, '$1.$2').toLowerCase();
}
module.exports = new RabbitClient();
The order service publishes a fact
// services/order-service/index.js
process.env.SERVICE_NAME = 'order-service';
const express = require('express');
const rabbit = require('../../common/rabbit-client');
const app = express();
app.use(express.json());
const orders = {}; // a real app uses a database
app.post('/orders', async (req, res) => {
const { customerId, items } = req.body;
if (!customerId || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'Invalid order' });
}
const orderId = `ORD-${Date.now()}`;
const total = items.reduce((s, i) => s + i.price * i.quantity, 0);
const order = { orderId, customerId, items, totalAmount: total, status: 'CREATED' };
orders[orderId] = order;
await rabbit.publishEvent('OrderPlaced', order); // announce and move on
res.status(201).json({ orderId, status: order.status });
});
rabbit.connect().then(() => {
// React to a downstream fact to advance our own state.
rabbit.subscribe('PaymentProcessed', async ({ data }) => {
const order = orders[data.orderId];
if (!order) return;
order.status = data.status === 'SUCCESS' ? 'PAID' : 'PAYMENT_FAILED';
await rabbit.publishEvent('OrderUpdated', { orderId: order.orderId, status: order.status });
});
app.listen(3001, () => console.log('Order service on :3001'));
});
The payment service reacts
// services/payment-service/index.js
process.env.SERVICE_NAME = 'payment-service';
const rabbit = require('../../common/rabbit-client');
rabbit.connect().then(() => {
rabbit.subscribe('OrderPlaced', async ({ data: order }) => {
console.log(`Charging ${order.totalAmount} for ${order.orderId}`);
const success = Math.random() > 0.2; // 80% succeed (demo)
await new Promise((r) => setTimeout(r, 800)); // simulate a gateway call
await rabbit.publishEvent(
success ? 'PaymentProcessed' : 'PaymentFailed',
{
orderId: order.orderId,
amount: order.totalAmount,
status: success ? 'SUCCESS' : 'FAILED',
transactionId: `TXN-${Date.now()}`,
}
);
});
console.log('Payment service ready');
});
Notification and inventory services follow the same shape: subscribe to the events they care about, do their work, publish new events. No service imports another. To add SMS alerts, you write a new subscriber β nothing else changes.
Watching one order ripple through
One HTTP call from the client triggers a cascade of events, each service reacting and emitting the next fact β the client long since answered. That's event-driven architecture doing its job.
Practice & Quiz
ποΈ Exercise 1: Run an event storm
Goal: Pick a domain (ride-hailing, hotel booking, food delivery) and list its events in past tense, in chronological order. For each, note the producer and which services would subscribe.
π‘ Hint
Walk the happy path as a user experiences it, then name each state change as a past-tense fact. Ask "who else in the business cares that this happened?"
β Solution
// Ride-hailing (one reasonable answer):
// RideRequested (Rider App) -> Matching, Analytics
// DriverMatched (Matching) -> Rider App, Driver App, Notifications
// RideStarted (Driver App) -> Billing, Analytics
// RideCompleted (Driver App) -> Billing, Ratings, Notifications
// PaymentCharged (Billing) -> Notifications, Analytics, Accounting
// Note every name is past tense (a fact), and no producer knows or
// lists its consumers β that's what keeps the system extensible.
ποΈ Exercise 2: Choreography or orchestration?
Goal: For each scenario, decide which coordination style fits and justify it in a sentence.
- A) Sending a welcome email, updating analytics, and provisioning a demo when a user signs up.
- B) A multi-step loan approval with credit checks, underwriting, and mandatory rollback on any rejection.
β Solution
// A) Choreography. The reactions are independent and purely additive β
// each service subscribes to UserRegistered and does its own thing.
// No central control or rollback is needed, so maximize autonomy.
// B) Orchestration (a Saga). The steps are ordered, interdependent, and
// a rejection must trigger compensating actions across services.
// A central coordinator makes the flow and its error handling explicit.
π― Quick Quiz
Question 1: Which is a properly named event?
Question 2: In choreography, who controls the overall flow?
Question 3: In event sourcing, how is current state obtained?
Best Practices & Pitfalls
β Do
- Name events as immutable, past-tense facts; give each a unique
eventId - Give every consumer its own queue so services fail and scale independently
- Propagate a
correlationIdto trace one action across every service - Design for eventual consistency and make handlers idempotent
- Version event schemas and evolve them backward-compatibly
- Reach for orchestration + Sagas when a flow needs ordered steps and rollback
β Don't
- Name events like commands (
ProcessPayment) β it invites tight coupling - Assume events arrive in order or exactly once
- Let a consumer call back to the producer on every event β that rebuilds the coupling you removed
- Choreograph a complex flow with rollbacks β the logic scatters and becomes untraceable
- Reach for event sourcing everywhere; use it where audit and history truly pay off
β οΈ Debugging goes distributed
A single user action now spans many services and queues, so a stack trace no longer tells the whole story. Invest early in correlation ids, centralized logging, and distributed tracing (Jaeger, Zipkin, or OpenTelemetry). Without them, "why didn't this order ship?" becomes an archaeology dig.
Summary
π Key Takeaways
- Producers publish events (past-tense facts) and stay ignorant of consumers β new features are new subscribers
- Pub/sub over a topic exchange is the engine; each service owns its queue for independent failure and scale
- Choose event-carried state transfer (or a hybrid) to avoid callback coupling
- Choreography maximizes autonomy; orchestration + Sagas give ordered flows with compensating rollback
- Event sourcing stores history and derives state by replay β powerful, but not a default
- You trade immediate consistency for eventual consistency; design the UI and handlers for the gap
π Additional Resources
- Martin Fowler β What do you mean by "Event-Driven"?
- microservices.io β Event Sourcing Pattern
- microservices.io β Saga Pattern
- CloudAMQP β Microservices & Message Queues
π What's Next?
You've now decoupled services with messages and events. Next we shift from how services talk to how clients ask for data, comparing two dominant API styles head to head in GraphQL vs REST.
π You think in events now!
Facts, pub/sub, choreography, sourcing, eventual consistency β the mental toolkit behind modern, resilient distributed systems.