Skip to main content

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

graph TD subgraph Request-driven A1["Order Service"] -->|calls| B1["Payment"] A1 -->|calls| C1["Inventory"] A1 -->|calls| D1["Notifications"] end subgraph Event-driven A2["Order Service"] -->|publishes OrderPlaced| E["Event Channel"] E --> B2["Payment"] E --> C2["Inventory"] E --> D2["Notifications"] end

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)
OrderPlacedPlaceOrder
PaymentProcessedProcessPayment
UserRegisteredRegisterUser

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.

graph LR OS["Order Service"] -->|order.placed| EX["Topic Exchange: events"] EX -->|binds order.placed| PQ["Payment Queue"] EX -->|binds order.placed| NQ["Notification Queue"] EX -->|binds order.hash| AQ["Analytics Queue"] PQ --> P["Payment Service"] NQ --> N["Notification Service"] AQ --> A["Analytics Service"]

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.

graph LR O["Order Service"] -->|OrderCreated id 123| E["Event Channel"] E --> Em["Email Service"] Em -->|GET order 123| O

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.

graph LR O["Order Service"] -->|OrderCreated with full order data| E["Event Channel"] E --> Em["Email Service"] E --> An["Analytics Service"]

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.

ChoreographyOrchestration
ControlDecentralized β€” each service reacts to events and emits its ownCentralized β€” one coordinator directs each step
CouplingLoose; services don't know the overall flowTighter; the orchestrator knows every step
VisibilityFlow is emergent β€” harder to see end to endFlow lives in one place β€” easy to read
Best forSimple, additive flows; maximum autonomyComplex flows needing clear control and error handling

Choreography: services dance to the music

graph LR OS["Order Service"] -->|OrderPlaced| PS["Payment Service"] PS -->|PaymentProcessed| IS["Inventory Service"] IS -->|ItemsReserved| SS["Shipping Service"] SS -->|OrderShipped| NS["Notification Service"]

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

graph TD ORC["Order Orchestrator"] -->|1 charge| PS["Payment Service"] ORC -->|2 reserve| IS["Inventory Service"] ORC -->|3 ship| SS["Shipping Service"] ORC -->|4 notify| NS["Notification Service"]

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.

graph LR U["User Actions"] --> EV["Events"] EV --> ES["Event Store
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

sequenceDiagram participant C as Client participant OS as Order Service participant PS as Payment Service participant NS as Notification Service C->>OS: POST create an order OS->>OS: Save the order OS-->>C: Return the order id right away OS->>PS: Publish OrderPlaced PS->>PS: Process the payment PS->>OS: Publish PaymentProcessed PS->>NS: Publish PaymentProcessed OS->>OS: Update order status to PAID NS->>NS: Email the payment receipt

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 correlationId to 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

πŸš€ 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.