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:

ScopeToolUse Case
In-processCustom EventBus, Node EventEmitterMonolith, single service
Service-to-serviceRedis Pub/Sub, NATSLow-latency, ephemeral events
Durable messagingRabbitMQ, Amazon SQSGuaranteed delivery, work queues
Event streamingApache Kafka, RedpandaHigh-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.