Event-Driven Architecture Explained Through Real Web Development Examples
Picture this: a user places an order on your e-commerce platform. Your order service saves the record to the database and then, synchronously, calls the inventory service to reserve stock, calls the email service to send a confirmation, calls the analytics service to log the conversion, calls the fulfillment service to queue the shipment, and calls the loyalty service to credit reward points. Each call is a network hop. Each hop can fail. If the analytics service is having a slow moment, the user waits. If the fulfillment service is down, the entire order fails even though payment already processed. You've built a system where one slow dependency makes everything slow, and one failing dependency can make everything fail.
This is the problem event-driven architecture exists to solve, and it's not a problem that only appears at Netflix-scale. It appears the moment your monolith starts accumulating synchronous side effects on critical user-facing operations.
Event-driven architecture (EDA) shifts the communication model: instead of Service A directly calling Services B, C, and D, Service A publishes an event to a broker and moves on. Services B, C, and D each subscribe to that event and process it independently, at their own pace, without Service A knowing or caring whether any of them exist. The order service publishes order.created and considers its job done. Everything downstream is decoupled, independently scalable, and independently failure-isolated.
The concept is well-known. The implementation details that determine whether it actually works in production are less often explained. This article walks through the real mechanics using concrete web development scenarios.
The Vocabulary: Events, Commands, and Queries
Before implementation, the terminology matters because conflating these concepts leads to architectures that look event-driven but behave like distributed monoliths.
An event is a fact about something that happened. It is past-tense, immutable, and doesn't dictate what should be done in response. order.created, user.registered, payment.failed are events. The publisher has no knowledge of what consumers will do with them, and ideally no interest.
A command is a directive telling another service to do something specific. send-confirmation-email, reserve-inventory, process-refund are commands. Commands have an intended recipient and an expected outcome. They're appropriate for some inter-service communication, but they're not events — and mixing the two patterns is a common source of tight coupling in systems that claim to be event-driven.
A query is a request for current state. It has a synchronous response and is appropriate for use cases where the caller needs data before it can proceed.
A well-designed event-driven system publishes events and lets consumers decide how to react. A poorly designed one publishes commands disguised as events — send-welcome-email.requested is a command with a past-tense name, not an event. The tell is whether the publisher had to know that a specific consumer exists to name the event. If yes, it's a command.
Pattern 1: Fan-Out Processing After User Registration
User registration is the textbook entry point for event-driven architecture because the post-registration workflow is almost universally a fan-out: send a welcome email, create a default workspace, log to analytics, start an onboarding sequence, notify a Slack channel for sales. None of these need to happen before the user gets their response. All of them are independently useful and independently fallible.
The Synchronous Version and Its Costs
// The tightly coupled version — every side effect blocks the response
async function registerUser(userData) {
const user = await db.createUser(userData);
// If any of these fail, what do you do?
await emailService.sendWelcomeEmail(user); // Network call 1
await workspaceService.createDefaultWorkspace(user); // Network call 2
await analyticsService.logSignup(user); // Network call 3
await crmService.createContact(user); // Network call 4
return user; // User waits for all four
}
Four network calls that must all succeed before the registration response returns. Total latency is the sum of all four, not the maximum.
The Event-Driven Version
// Event-driven: publish the fact, consumers handle side effects
async function registerUser(userData) {
const user = await db.createUser(userData);
// Publish one event — all side effects happen asynchronously
await eventBus.publish('user.registered', {
userId: user.id,
email: user.email,
plan: user.plan,
registeredAt: new Date().toISOString(),
source: userData.acquisitionSource
});
return user; // Returns immediately after publish
}
// Each consumer is independent — failure in one doesn't affect others
// email-service/consumers/user-registered.js
eventBus.subscribe('user.registered', async (event) => {
await sendWelcomeEmail(event.email, event.userId);
});
// workspace-service/consumers/user-registered.js
eventBus.subscribe('user.registered', async (event) => {
await createDefaultWorkspace(event.userId, event.plan);
});
// analytics-service/consumers/user-registered.js
eventBus.subscribe('user.registered', async (event) => {
await logSignupConversion(event.userId, event.source);
});
The registration endpoint now has one database write and one message publish. Response time is no longer the sum of four services. Adding a fifth side effect — say, a new onboarding email sequence — requires zero changes to the registration service. The consumer subscribes to the existing event independently.
Pattern 2: Order Processing With Compensating Transactions
Order processing is where event-driven architecture gets genuinely hard, because an order involves multiple services that must either all succeed or all be rolled back — a distributed transaction problem that events make tractable through the Saga pattern.
A saga is a sequence of local transactions where each step publishes an event that triggers the next step. If any step fails, compensating transactions undo the previous steps.
The Choreography Saga
In a choreography-based saga, there's no central coordinator — each service reacts to events and publishes its own success or failure events.
order.created
→ InventoryService: reserve-stock
→ inventory.reserved (success)
→ PaymentService: charge-card
→ payment.processed (success)
→ FulfillmentService: queue-shipment
→ shipment.queued (success) ✓
→ payment.failed (failure)
→ InventoryService: release-reserved-stock (compensate)
→ inventory.insufficient (failure)
→ OrderService: mark-order-failed (compensate)
// inventory-service/consumers/order-created.js
eventBus.subscribe('order.created', async (event) => {
const { orderId, items } = event;
try {
await db.reserveStock(items);
await eventBus.publish('inventory.reserved', {
orderId,
reservationId: generateId(),
items,
reservedAt: new Date().toISOString()
});
} catch (err) {
if (err.code === 'INSUFFICIENT_STOCK') {
await eventBus.publish('inventory.insufficient', {
orderId,
reason: err.message,
unavailableItems: err.items
});
}
}
});
// inventory-service/consumers/payment-failed.js
// Compensating transaction: release the reserved stock if payment fails
eventBus.subscribe('payment.failed', async (event) => {
const { orderId } = event;
const reservation = await db.getReservationByOrderId(orderId);
if (reservation) {
await db.releaseReservation(reservation.id);
await eventBus.publish('inventory.reservation-released', {
orderId,
reservationId: reservation.id
});
}
});
The Orchestration Saga
Choreography works well for linear flows but becomes hard to reason about when the flow branches significantly. Orchestration introduces a central coordinator — a saga orchestrator — that explicitly manages state and issues commands to each service.
// order-saga-orchestrator.js
class OrderSagaOrchestrator {
async start(orderId) {
const saga = await db.createSaga({ orderId, status: 'STARTED', step: 'RESERVE_INVENTORY' });
await commandBus.send('inventory.reserve', { orderId, sagaId: saga.id });
}
async handleInventoryReserved(event) {
const saga = await db.getSagaByOrderId(event.orderId);
await db.updateSaga(saga.id, { step: 'CHARGE_PAYMENT' });
await commandBus.send('payment.charge', {
orderId: event.orderId,
sagaId: saga.id,
amount: saga.totalAmount
});
}
async handlePaymentFailed(event) {
const saga = await db.getSagaByOrderId(event.orderId);
// Initiate compensation
await db.updateSaga(saga.id, { step: 'COMPENSATING', status: 'FAILED' });
await commandBus.send('inventory.release', {
orderId: event.orderId,
sagaId: saga.id
});
}
async handleInventoryReleased(event) {
const saga = await db.getSagaByOrderId(event.orderId);
await db.updateSaga(saga.id, { step: 'COMPLETED', status: 'FAILED' });
// Notify customer of failed order
await eventBus.publish('order.failed', { orderId: event.orderId, reason: saga.failureReason });
}
}
The orchestrator holds the saga state explicitly, making it possible to inspect any order's saga state in the database for debugging, resuming after crashes, and auditing. This is the pattern used by most e-commerce platforms at scale, where the choreography approach's implicit state becomes impossible to observe and debug.
The Message Broker Decision: Kafka vs RabbitMQ vs SQS
The event bus in the examples above isn't an abstraction to gloss over — the broker choice determines durability guarantees, ordering semantics, throughput ceiling, and operational complexity. Three choices dominate production web systems.
Apache Kafka: The Right Choice for High-Throughput Event Streams
Kafka is a distributed append-only log. Events are written to partitions, retained for a configurable period, and consumed by consumer groups that track their own offset. The retention model means consumers can replay historical events, process events at their own pace, and start from any point in history — properties that don't exist in traditional message queues.
// Kafka producer: publishing an event
const { Kafka } = require('kafkajs');
const kafka = new Kafka({ brokers: ['kafka:9092'] });
const producer = kafka.producer();
async function publishEvent(topic, event) {
await producer.connect();
await producer.send({
topic,
messages: [{
key: event.userId || event.orderId, // Partition key — ensures ordering per entity
value: JSON.stringify(event),
headers: {
eventType: event.type,
eventVersion: '1.0',
publishedAt: new Date().toISOString()
}
}]
});
}
// Kafka consumer: processing events with manual offset management
const consumer = kafka.consumer({ groupId: 'email-service' });
async function startConsuming() {
await consumer.connect();
await consumer.subscribe({ topic: 'user.events', fromBeginning: false });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const event = JSON.parse(message.value.toString());
try {
await processEvent(event);
// Offset commits automatically after successful processing
} catch (err) {
// Dead letter queue handling
await publishToDLQ(topic, message, err);
}
}
});
}
Kafka's partition key is critical for ordering: events with the same key are guaranteed to land on the same partition, preserving order for events belonging to the same entity (all events for order:12345 arrive in order). Events with different keys can be processed in parallel.
Use Kafka when: High throughput (millions of events/day), event replay capability is needed, multiple independent consumer groups need to process the same events, or you're building an event sourcing system.
RabbitMQ: When Work Queue Semantics Fit Better
RabbitMQ is a traditional message broker — messages are consumed and acknowledged, then deleted. It offers rich routing via exchanges and binding patterns that Kafka doesn't natively support.
const amqp = require('amqplib');
// Publisher
async function publishEvent(exchange, routingKey, event) {
const conn = await amqp.connect('amqp://rabbitmq');
const channel = await conn.createChannel();
await channel.assertExchange(exchange, 'topic', { durable: true });
channel.publish(
exchange,
routingKey, // e.g., 'order.created.premium' — fine-grained routing
Buffer.from(JSON.stringify(event)),
{ persistent: true, contentType: 'application/json' }
);
}
// Consumer with dead-letter exchange
async function startConsumer(queue, exchange, bindingPattern) {
const conn = await amqp.connect('amqp://rabbitmq');
const channel = await conn.createChannel();
await channel.assertQueue(queue, {
durable: true,
arguments: {
'x-dead-letter-exchange': `${exchange}.dlx`,
'x-message-ttl': 86400000 // 24hr TTL before DLQ
}
});
await channel.bindQueue(queue, exchange, bindingPattern);
channel.consume(queue, async (msg) => {
try {
const event = JSON.parse(msg.content.toString());
await processEvent(event);
channel.ack(msg);
} catch (err) {
// Negative ack — sends to dead-letter exchange after max retries
channel.nack(msg, false, false);
}
});
}
Use RabbitMQ when: Work queue semantics are needed (each message processed once), complex routing rules apply, message TTL and priority queuing matter, or the team prefers operational simplicity over Kafka's scaling ceiling.
AWS SQS + SNS: The Managed Fan-Out Pattern
For teams running on AWS, SNS (Simple Notification Service) + SQS (Simple Queue Service) provides managed fan-out without operating a Kafka cluster: SNS publishes a message to multiple SQS queues simultaneously, each consumed independently by different services.
┌─────────────────┐
│ SNS Topic │
│ user.registered │
└────────┬────────┘
┌───────┼──────────┐
▼ ▼ ▼
[SQS Queue] [SQS Queue] [SQS Queue]
EmailService WorkspaceService AnalyticsService
This is the entry point for event-driven architecture on AWS — minimal operational overhead, reliable delivery, and native integration with Lambda for event consumers.
Idempotency: The Non-Negotiable Requirement
Every event consumer in a production system must be idempotent — processing the same event twice must produce the same result as processing it once. Message brokers guarantee at-least-once delivery, not exactly-once. Network timeouts, consumer crashes during processing, and broker retries all cause duplicate delivery.
// Non-idempotent consumer — processes duplicates, causes double-billing
eventBus.subscribe('order.completed', async (event) => {
await loyaltyService.creditPoints(event.userId, event.totalAmount); // Doubles on retry
});
// Idempotent consumer — safe to process multiple times
eventBus.subscribe('order.completed', async (event) => {
const idempotencyKey = `loyalty:credit:${event.orderId}`;
// Check if this event has already been processed
const alreadyProcessed = await redis.get(idempotencyKey);
if (alreadyProcessed) {
console.log(`Event ${event.orderId} already processed, skipping`);
return;
}
await loyaltyService.creditPoints(event.userId, event.totalAmount);
// Mark as processed with TTL longer than max retry window
await redis.setex(idempotencyKey, 86400, 'processed');
});
The idempotency key should be derived from the event's natural identity — the order ID, the transaction ID, the event UUID — not from a timestamp or a random value generated at processing time. The key must survive consumer restarts and must be checkable before any side effects execute.
Observability in Event-Driven Systems
Distributed tracing becomes critical once requests span multiple services connected by async events. A user-facing request that triggers five downstream consumers isn't traceable with traditional request logging — you need a trace context that propagates through the event payload itself.
const { trace, context, propagation } = require('@opentelemetry/api');
// Publisher: inject trace context into event headers
async function publishWithTracing(topic, event) {
const span = trace.getActiveSpan();
const traceHeaders = {};
// Propagate W3C trace context into the event
propagation.inject(context.active(), traceHeaders);
await eventBus.publish(topic, {
...event,
_traceContext: traceHeaders // Carries trace ID into async processing
});
}
// Consumer: extract and continue the trace
eventBus.subscribe('order.created', async (event) => {
const parentContext = propagation.extract(
context.active(),
event._traceContext || {}
);
const tracer = trace.getTracer('inventory-service');
await context.with(parentContext, async () => {
const span = tracer.startSpan('process-order-created');
try {
await reserveInventory(event);
span.setStatus({ code: 1 }); // OK
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2 }); // ERROR
throw err;
} finally {
span.end();
}
});
});
With trace context propagated through events, tools like Jaeger or Datadog APM can reconstruct the full execution path of a user action across every asynchronous consumer — making event-driven systems debuggable rather than opaque.
Teams building complex, multi-service applications — like those handling website development in Crystal Lake, Illinois for clients scaling from a monolith to a distributed architecture — consistently find that distributed tracing is the investment that makes event-driven systems maintainable in practice, not just theoretically sound on a whiteboard.
When Event-Driven Architecture Is the Wrong Choice
EDA introduces real complexity: broker infrastructure, consumer deployment, idempotency implementation, distributed tracing, dead-letter queue management, and saga state machines for multi-step workflows. That complexity is worth paying when it replaces worse problems — tight coupling, cascading failures, synchronous scaling bottlenecks. It's not worth paying when those problems don't exist yet.
Signs EDA is premature:
- A single-team monolith with fewer than five backend services
- Workflows where strong transactional consistency is required and compensating transactions aren't acceptable (financial ledgers, for example, often belong in a single ACID-compliant database rather than distributed sagas)
- Teams without operational experience running a message broker in production
- Low event volume where the overhead of a broker exceeds its value
The right entry point for most teams is extracting one high-value fan-out scenario — user registration side effects, or post-payment processing — into an event-driven pattern using a managed broker like SQS, before building toward a fully event-driven architecture. This builds operational familiarity with the pattern on a lower-risk surface before applying it to the core business flow.
Engineers at teams doing website development in Bartlett, Illinois for SaaS clients frequently describe the same adoption path: start with a single event for user registration or order completion, prove the pattern works operationally, then extend it incrementally rather than attempting a big-bang architecture migration that stalls under its own scope.
A Production-Ready Event Schema Design
Before wrapping up, the event schema itself deserves attention. Poorly designed event schemas create the same coupling problems they were meant to solve — consumers fail when producers add fields, version mismatches cause silent data loss, and debugging is impossible without knowing which schema version produced which event.
// A well-structured event envelope
const event = {
// Identity and routing
id: 'evt_01HX9K4M7P3Y2Z8NQRST', // Globally unique — use ULIDs or UUIDs v7
type: 'order.created', // Namespaced, past-tense, dot-separated
version: '2.1', // Schema version — consumers check this
// Tracing and audit
correlationId: 'req_0987654321', // Links back to the originating request
causationId: 'evt_prevEventId', // Which event caused this one (for sagas)
publishedAt: '2026-06-15T10:23:45.123Z',
source: 'order-service',
// Business data
data: {
orderId: 'ord_5678',
userId: 'usr_1234',
totalAmount: 9999,
currency: 'USD',
items: [
{ sku: 'WIDGET-001', quantity: 2, unitPrice: 4999 }
]
}
};
The version field enables consumers to handle schema evolution gracefully — a consumer on schema v2 can explicitly handle a v1 event with a migration path, or reject it cleanly, rather than silently processing a structurally different payload.
Summary
Event-driven architecture solves a real class of problems in web development: cascading failure risk from synchronous side effects, tight coupling between services, and scaling bottlenecks on high-traffic operations. The implementation requires deliberate handling of idempotency, message broker selection matched to workload characteristics, distributed tracing for observability, and saga patterns for multi-step transactional workflows.
The path from a coupled synchronous system to a well-functioning event-driven one runs through one use case at a time, not a wholesale architectural replacement. Pick the fan-out scenario that causes the most pain in your current system, implement it on a managed broker with proper idempotency and a dead-letter queue, instrument it with distributed tracing, and prove the pattern before extending it. The complexity is real — but so is the problem it solves, once your system reaches the point where synchronous coupling becomes the ceiling on reliability and scale.
Comments
Post a Comment