Architecture

Event-Driven Architecture

Components communicate by publishing and consuming events, with no direct calls between them. Producers don't know consumers — and consumers don't know producers — making the system highly decoupled and extensible by addition, not modification.

Intent

Replace direct calls between components with asynchronous messages that describe what happened in the system. In Event-Driven Architecture (EDA), a component that does something relevant publishes an event; any interested component consumes it and acts independently. Neither needs to know the other exists.

This decoupling is EDA's fundamental property. Adding new behavior to the system — sending an email when an order is confirmed, updating a cache, incrementing a metric — means adding a new consumer of the existing event, without modifying the producer. The Open/Closed principle manifests here naturally.

EDA can be implemented within a single process (in-process, using a lightweight event bus) or across distributed services (via brokers like Kafka, RabbitMQ, AWS SNS/SQS). The architecture is the same; the topology and delivery guarantees differ. The more precise term for the inter-process case is messaging or message-driven architecture, but EDA is the most common term in the literature.

Problem

In systems where components call each other directly, growth produces a set of problems that intensify over time:

  • Temporal coupling: the caller needs the receiver to be available at the exact moment of the call. A receiver failure fails the caller. In distributed systems, this creates cascading failures.
  • Knowledge coupling: the component that confirms an order needs to know and explicitly call the email service, the inventory service, the loyalty service and the analytics service. Every new business requirement modifies the same central component.
  • Difficulty extending: adding new behavior (e.g., notifying the logistics partner after an order is confirmed) requires modifying existing code, risking regressions in unrelated functionality.
  • Synchronous scalability: operations that could run in parallel (sending an email and updating inventory are independent of each other) run sequentially because the caller waits for each response before continuing.

Structure

In EDA, producers publish events to a broker (or bus), and consumers register to receive the event types they care about. The broker is the only shared point of knowledge — producers and consumers know each other only through the event's contract (its schema).

   PRODUCERS                                     BROKER / BUS                  CONSUMERS
  ┌─────────────┐                               ┌──────────────┐              ┌───────────────────┐
  │    Order    │──publishes──▶ OrderConfirmed  │  event bus   │──delivers──▶ │ Email Service     │
  │   Service   │                               │   / broker   │              └───────────────────┘
  └─────────────┘                               │              │
                                                │              │              ┌───────────────────┐
  ┌─────────────┐                               │              │──delivers──▶ │ Inventory Service │
  │   Payment   │──publishes──▶ PaymentApproved │              │              └───────────────────┘
  │   Service   │                               │              │              ┌───────────────────┐
  └─────────────┘                               │              │──delivers──▶ │ Loyalty Service   │
                                                └──────────────┘              └───────────────────┘

  Contract: only the event schema is shared.
  Producers don't know the consumers.
  Consumers don't know the producers.
  Consumers don't know each other.

The three patterns within EDA

Martin Fowler distinguishes three variants that differ in what the event carries and what the consumer needs to do with it. Most design mistakes in EDA come from confusing the three.

  • Event Notification: the event signals that something happened. It carries the minimum needed to identify the fact — typically the aggregate's ID and the event type. The consumer, if it needs more detail, has to make a call back to the producer to fetch it. It's the simplest pattern and the most decoupled in terms of payload — but it introduces an extra synchronous call when the consumer needs the data.
  • Event-Carried State Transfer (ECST): the event carries enough state that the consumer can act without needing to call the producer back. The consumer keeps a local copy of the data it needs — essentially a cache updated via events. It eliminates the extra synchronous call, but increases the payload size and creates a dependency on the event schema: changes to the event's structure affect every consumer.
  • Event Sourcing (ES): events aren't just notifications — they're the source of truth. The system's current state isn't stored directly; it's derived by applying the sequence of events in order. The database is replaced by an immutable log of events. ES is the most powerful and the most complex: perfect auditing, state reconstruction at any point in time, but requires handling event versioning, rehydration and projections.

Where most people get it wrong: Event Notification and ECST are communication strategies — the system's state still exists in some database. Event Sourcing is a persistence strategy — state is derived from the events. Using "Event Sourcing" when you mean "Event Notification" is the most common terminology mistake in EDA.

How it works

The snippet below shows the three patterns side by side for the same domain event, illustrating the differences in payload and in what the consumer needs to do.

// ── Event Notification: minimal payload ─────────────────────
interface OrderConfirmedNotification {
  readonly type: 'OrderConfirmed';
  readonly orderId: string;        // consumer fetches the rest if needed
  readonly occurredAt: string;
}

// ── Event-Carried State Transfer: sufficient payload ─────────
interface OrderConfirmedECST {
  readonly type: 'OrderConfirmed';
  readonly orderId: string;
  readonly customerEmail: string;  // consumer doesn't need to fetch it
  readonly total: number;
  readonly items: { productId: string; quantity: number }[];
  readonly occurredAt: string;
}

// ── Event Sourcing: the event IS the state ───────────────────
// The aggregate is reconstructed by applying events in order
class OrderAggregate {
  private id = '';
  private status = 'new';
  private items: Item[] = [];

  apply(event: DomainEvent): void {
    if (event.type === 'OrderConfirmed') {
      this.status = 'confirmed';
    }
    // each event mutates the aggregate's state
  }
}

// ── Publishing (producers don't know the consumers) ───────────
class OrderService {
  confirm(orderId: string): void {
    // ... business logic ...
    this.eventBus.publish({ type: 'OrderConfirmed', orderId, occurredAt: new Date().toISOString() });
    // does not call EmailService directly
  }
}

When to use

  • Integration between independent services: when multiple services need to react to the same business fact without any of them being the central orchestrator, EDA is the most natural form of integration. Each service reacts autonomously, at its own pace and with its own guarantees.
  • Extensibility by addition: when the business requirement is frequently "add more reactions to the same fact," EDA is more sustainable than direct calls. Adding a consumer doesn't modify the producer or existing consumers.
  • Decoupling from availability: when the producer can't wait for every consumer to be available at the moment of publishing — queues guarantee the event will be processed once the consumer is ready.
  • Auditing and traceability: the event log is a natural audit trail of everything that happened in the system, with a timestamp and complete payload for each occurrence.

When to avoid

  • Flows that require an immediate, synchronous response: if the user's request needs a response computed from data across multiple services, a synchronous call (RPC, HTTP) is simpler and more direct. Trying to simulate synchrony with EDA (correlation ID, reply queue) adds complexity without an equivalent benefit.
  • Simple business logic within a single service: inside a monolith with simple business logic, an internal event bus adds unnecessary indirection. Direct calls between classes or modules are more readable and easier to debug.
  • Teams without experience with asynchronous systems: debugging failures in event-driven systems — tracing why an event wasn't processed, investigating ordering, handling duplicates — requires specific tools and experience. The operational learning curve is significant.

Pros and cons

Pros

  • Structural decoupling: producers and consumers evolve independently — adding a new consumer doesn't touch the producer.
  • Extensibility by addition: new behaviors arise from new consumers, without modifying existing code.
  • Resilience: message queues absorb spikes and let slow or temporarily unavailable consumers process events at their own pace.
  • Traceability: the event log is a native audit trail of all system changes.
  • Independent scalability: producers and consumers scale separately, matching each one's volume.

Cons

  • Hard debugging: tracing the causal flow of an operation requires correlating events across multiple services, logs and timestamps — observability tools (distributed tracing) are essential.
  • Eventual consistency: the system becomes consistent over time, not instantly. Flows that require immediate consistency need workarounds.
  • Operational complexity: message brokers (Kafka, RabbitMQ) add infrastructure that needs to be configured, monitored and operated.
  • Ordering and idempotency: guaranteeing that events are processed in the correct order and exactly once (or that the consumer is idempotent) is non-trivial in distributed systems.
  • Coupled schema: consumers depend on event schemas. Changes break consumers — requiring schema versioning, backward/forward compatibility, or a central schema registry.

Common pitfalls

1. Confusing EDA with the Observer pattern

Observer (GoF) and EDA share the idea of publishing and consuming notifications, but differ in scope, synchrony and topology. Observer is in-process: the subject directly calls the registered observers' methods, in the same process, typically synchronously. EDA is inter-process: producers publish messages to an external broker, and consumers process them asynchronously, possibly in different processes, on different machines. In Observer, the subject knows the observers' interface; in EDA, the producer doesn't know any consumer.

Quick summary: Observer = same process, direct call, possibly synchronous. EDA = across processes/services, mediated by a broker, asynchronous by design.

2. Debugging without observability

In a synchronous system, a stack trace tells the whole story of a failure. In EDA, an event published by Service A is consumed by Service B, which publishes another event consumed by Service C. If C fails, the cause might be in A. Without distributed tracing (OpenTelemetry, Jaeger, Zipkin) and without a correlation ID field propagated through every event, investigating a failure becomes an archaeology exercise across multiple logs. Observability isn't optional in EDA — it's part of the architecture.

3. Ignoring ordering and idempotency

Message brokers generally guarantee at-least-once delivery, not exactly-once. Events can be delivered out of publication order and can be delivered more than once. Consumers that aren't idempotent produce duplicate side effects. Consumers that assume ordering can process an OrderCancelled before the OrderConfirmed. Design idempotent consumers from the start — not as a later optimization.

4. Coupling via unversioned event schemas

The event schema is the contract between producer and consumers. Backward-compatible changes (adding an optional field) are safe. Incompatible changes (removing a field, renaming a type) break every consumer at once. In production systems, this requires versioning strategies: optional fields with default values, versioned events (OrderConfirmedV2), version transformers, or a schema registry with compatibility validation.

5. Event sprawl: events without governance

Without governance, the event catalog grows uncontrollably: events with inconsistent names, overlapping payloads, semantic duplicates and ambiguous responsibilities. Over time, nobody knows which events exist, who produces them and who consumes them. An event catalog with an explicit owner, documented schema and version history is an essential part of governing EDA at scale.

Related architectures and patterns

Observer (GoF) is EDA's conceptual ancestor, but operates at a radically different scope. In Observer, the subject holds a list of observers and notifies them by directly calling their methods in the same process. In EDA, communication is mediated by an external broker, asynchronous and without a direct reference. An in-process event bus (like Node.js's EventEmitter or a SimpleEventBus) is technically an Observer — EDA begins when communication crosses the process boundary.

CQRS and EDA combine frequently: the write side's CommandHandler publishes a domain event after persisting a change; that event feeds the read side's Read Model. CQRS solves the problem of distinct read and write models; EDA solves the problem of propagating those changes in a decoupled way. Neither implies the other.

In Microservices, EDA is often the glue that keeps services decoupled in time and availability. Each service publishes domain events that other services consume to keep their own local data up to date — eliminating synchronous calls between services for frequent reads.