Event-Driven Architecture: Decoupling Done Right
Stop coupling your modules with direct calls. Learn how event emitters, message buses, and pub/sub patterns create truly decoupled systems that are easier to extend and maintain.
You’ve built a clean service. Then product says: “When a user signs up, also send a welcome email, create a Stripe customer, log an analytics event, and notify the sales team on Slack.” Suddenly your registerUser function looks like this:
async function registerUser(data: RegistrationData): Promise<User> {
const user = await userRepository.create(data);
await emailService.sendWelcome(user); // Why does registration know about emails?
await stripeService.createCustomer(user); // ...or Stripe?
await analyticsService.track("user_registered", user); // ...or analytics?
await slackService.notify("#sales", `New user: ${user.email}`); // ...or Slack?
return user;
}
This function now has five responsibilities and six reasons to change. If the email service is down, registration fails. If you add a seventh side effect next week, you modify the same function again.
Event-driven architecture fixes this by inverting the dependency: instead of the producer calling every consumer, the producer announces what happened, and consumers decide what to do about it.
The Core Idea: Events as Facts
An event is a fact about something that already happened. It’s past tense, immutable, and carries just enough context:
// Events are facts — past tense, immutable
interface UserRegistered {
type: "UserRegistered";
payload: {
userId: string;
email: string;
name: string;
registeredAt: Date;
};
}
interface OrderPlaced {
type: "OrderPlaced";
payload: {
orderId: string;
customerId: string;
items: OrderItem[];
total: number;
};
}
The producer doesn’t know or care who’s listening. It just emits the fact:
async function registerUser(data: RegistrationData): Promise<User> {
const user = await userRepository.create(data);
eventBus.emit({
type: "UserRegistered",
payload: { userId: user.id, email: user.email, name: user.name, registeredAt: new Date() },
});
return user; // That's it. Registration only registers.
}
Building a Simple Event Bus
You don’t need Kafka or RabbitMQ to start. A typed in-process event bus is 30 lines:
type EventHandler<T = unknown> = (event: T) => void | Promise<void>;
class EventBus {
private handlers = new Map<string, EventHandler[]>();
on<T extends { type: string }>(eventType: T["type"], handler: EventHandler<T>): void {
const existing = this.handlers.get(eventType) ?? [];
existing.push(handler as EventHandler);
this.handlers.set(eventType, existing);
}
async emit<T extends { type: string }>(event: T): Promise<void> {
const handlers = this.handlers.get(event.type) ?? [];
await Promise.allSettled(
handlers.map(handler => handler(event))
);
}
off(eventType: string, handler: EventHandler): void {
const existing = this.handlers.get(eventType) ?? [];
this.handlers.set(eventType, existing.filter(h => h !== handler));
}
}
Notice Promise.allSettled instead of Promise.all — if the Slack notification fails, we don’t want to crash the email sender. Each handler is independent.
Now wire up the consumers:
const bus = new EventBus();
// Each handler is independent. Add/remove without touching registration.
bus.on<UserRegistered>("UserRegistered", async (event) => {
await emailService.sendWelcome(event.payload.email, event.payload.name);
});
bus.on<UserRegistered>("UserRegistered", async (event) => {
await stripeService.createCustomer(event.payload.userId, event.payload.email);
});
bus.on<UserRegistered>("UserRegistered", async (event) => {
await analyticsService.track("user_registered", { userId: event.payload.userId });
});
bus.on<UserRegistered>("UserRegistered", async (event) => {
await slackService.notify("#sales", `New signup: ${event.payload.email}`);
});
Adding a new side effect? Add a new handler. No existing code changes.
Python Implementation
Python’s approach can leverage asyncio or simple synchronous dispatch:
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Callable, Awaitable
import asyncio
@dataclass(frozen=True)
class UserRegistered:
user_id: str
email: str
name: str
@dataclass(frozen=True)
class OrderPlaced:
order_id: str
customer_id: str
total: float
EventHandler = Callable[[Any], Awaitable[None]]
class EventBus:
def __init__(self):
self._handlers: dict[type, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_type: type, handler: EventHandler) -> None:
self._handlers[event_type].append(handler)
async def publish(self, event: object) -> None:
handlers = self._handlers.get(type(event), [])
results = await asyncio.gather(
*(handler(event) for handler in handlers),
return_exceptions=True,
)
for result in results:
if isinstance(result, Exception):
logging.error(f"Handler failed: {result}")
# Usage:
bus = EventBus()
async def send_welcome_email(event: UserRegistered) -> None:
await email_service.send_welcome(event.email, event.name)
async def create_stripe_customer(event: UserRegistered) -> None:
await stripe_service.create_customer(event.user_id, event.email)
bus.subscribe(UserRegistered, send_welcome_email)
bus.subscribe(UserRegistered, create_stripe_customer)
Patterns Within Event-Driven Architecture
Fan-Out: One Event, Many Handlers
This is the most common pattern. One event triggers multiple independent reactions:
UserRegistered ──┬──► SendWelcomeEmail
├──► CreateStripeCustomer
├──► TrackAnalytics
└──► NotifySlack
Each handler can fail independently without affecting the others.
Event Chaining: Events Triggering Events
Sometimes a handler’s action produces its own event:
bus.on<OrderPlaced>("OrderPlaced", async (event) => {
const invoice = await billingService.createInvoice(event.payload);
bus.emit({ type: "InvoiceCreated", payload: { invoiceId: invoice.id, orderId: event.payload.orderId } });
});
bus.on<InvoiceCreated>("InvoiceCreated", async (event) => {
await pdfService.generateInvoicePdf(event.payload.invoiceId);
});
Be careful with event chains — they can become hard to trace. Keep chains short and document the flow.
Event Sourcing (The Advanced Version)
Instead of storing current state, store the sequence of events that produced it:
// Instead of: UPDATE users SET name = 'Bob' WHERE id = '123'
// Store events:
const events = [
{ type: "UserCreated", payload: { id: "123", name: "Alice" }, timestamp: "2024-01-01" },
{ type: "UserRenamed", payload: { id: "123", newName: "Bob" }, timestamp: "2024-03-15" },
{ type: "UserEmailChanged", payload: { id: "123", newEmail: "[email protected]" }, timestamp: "2024-06-01" },
];
// Reconstruct current state by replaying events:
function buildUserState(events: UserEvent[]): User {
return events.reduce((state, event) => {
switch (event.type) {
case "UserCreated": return { id: event.payload.id, name: event.payload.name, email: "" };
case "UserRenamed": return { ...state, name: event.payload.newName };
case "UserEmailChanged": return { ...state, email: event.payload.newEmail };
default: return state;
}
}, {} as User);
}
Event sourcing gives you a complete audit trail and time-travel debugging. But it adds significant complexity — don’t adopt it unless you need it.
Error Handling Strategies
Events introduce new failure modes. Plan for them:
Dead Letter Queue
When a handler fails after retries, park the event for manual investigation:
class ResilientEventBus extends EventBus {
private deadLetterQueue: Array<{ event: unknown; error: Error; handler: string }> = [];
async emit<T extends { type: string }>(event: T): Promise<void> {
const handlers = this.getHandlers(event.type);
for (const { name, handler } of handlers) {
try {
await this.withRetry(() => handler(event), 3);
} catch (error) {
this.deadLetterQueue.push({
event,
error: error as Error,
handler: name,
});
logger.error(`Handler "${name}" failed permanently for ${event.type}`, error);
}
}
}
private async withRetry(fn: () => Promise<void>, attempts: number): Promise<void> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
if (i === attempts - 1) throw error;
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000)); // Exponential backoff
}
}
}
}
Idempotent Handlers
Events might be delivered more than once (at-least-once delivery). Make handlers idempotent:
bus.on<OrderPlaced>("OrderPlaced", async (event) => {
// Idempotent: check if we already processed this
const existing = await invoiceRepo.findByOrderId(event.payload.orderId);
if (existing) {
logger.info(`Invoice already exists for order ${event.payload.orderId}, skipping`);
return;
}
await invoiceRepo.create(event.payload);
});
When to Use Event-Driven Architecture
Use it when:
- Multiple independent reactions to a single action (fan-out)
- You want to decouple modules that change at different rates
- Side effects shouldn’t block the primary operation
- You need an audit trail of what happened
- Different teams own different handlers
Don’t use it when:
- The operation requires all steps to succeed atomically (use a transaction instead)
- There are only 1-2 simple side effects (a function call is simpler)
- You need synchronous, ordered processing
- You can’t tolerate eventual consistency
Watch out for:
- Event storms — chains of events triggering more events exponentially
- Debugging difficulty — “what happens when X?” requires tracing through handlers
- Ordering assumptions — handlers may execute in any order
- Ghost handlers — forgotten handlers that still fire on old events
From In-Process to Distributed
When your in-process event bus outgrows a single service, you graduate to message brokers:
| Scope | Tool | Use Case |
|---|---|---|
| In-process | Custom EventBus, Node EventEmitter | Monolith, single service |
| Service-to-service | Redis Pub/Sub, NATS | Low-latency, ephemeral events |
| Durable messaging | RabbitMQ, Amazon SQS | Guaranteed delivery, work queues |
| Event streaming | Apache Kafka, Redpanda | High-throughput, event replay, audit |
Start with the simplest option that meets your needs. Most applications don’t need Kafka — they need a clean event bus and well-defined event contracts.
The key insight of event-driven architecture isn’t the technology. It’s the mindset shift: your code announces what happened instead of orchestrating what should happen next. That single inversion turns a tightly coupled monolith into a system that grows gracefully.