System Design ·

Event-Driven Architecture in Practice: When to Use It, When to Avoid It, and How to Get the Tradeoffs Right

A practical guide to event-driven architecture for senior engineers: what events actually are, the real benefits and costs, concrete decision criteria, common anti-patterns, and TypeScript implementation patterns with production considerations.

Event-Driven Architecture in Practice: When to Use It, When to Avoid It, and How to Get the Tradeoffs Right

Event-driven architecture gets oversold. You will find architecture diagrams with every service publishing to a broker and every other service subscribing, with arrows going in all directions and a note that says “scalable and decoupled.” What those diagrams hide is the operational complexity, the debugging pain, and the eventual consistency edge cases that will wake you up at 2am.

This article is about making the decision correctly. When EDA is the right call, why it is, what it genuinely costs you, and how to implement it without creating a distributed monolith held together by a message broker.

What an Event Actually Is

Before committing to the pattern, the vocabulary has to be precise. Three concepts get conflated constantly: events, commands, and queries.

A command is an instruction to do something. It has one intended recipient. It can be rejected. PlaceOrder, SendEmail, RefundPayment are commands. The sender cares about the outcome.

A query is a request for data. It expects a response. It has no side effects, or should not have any.

An event is a statement of fact about something that already happened. OrderPlaced, PaymentFailed, UserDeactivated. Events are past tense. They are immutable. The publisher does not know or care who listens. Multiple consumers can react independently.

This distinction is not academic. Designing a system where commands masquerade as events is the root of most EDA anti-patterns. If your “events” have a single known consumer and the publisher cares about the outcome, they are commands, and you should route them synchronously.

// This is a command dressed up as an event — do not do this
type SendWelcomeEmailEvent = {
  type: "SEND_WELCOME_EMAIL";
  userId: string;
};

// This is an actual event — something happened, consumers decide what to do
type UserRegisteredEvent = {
  type: "USER_REGISTERED";
  userId: string;
  email: string;
  registeredAt: string; // ISO 8601
};

The difference seems subtle until you have three teams arguing about who owns the SEND_WELCOME_EMAIL event and why the email service keeps getting blamed for order failures.

The Real Benefits

Temporal Decoupling

The publisher and consumer do not need to be available at the same time. The order service publishes OrderPlaced and moves on. The inventory service, the notification service, and the analytics pipeline each consume it on their own schedule. If the notification service is deploying, orders still process. If the analytics pipeline is catching up after a backfill, the order service does not slow down.

This is the single most important property of EDA. In a synchronous system, a slow dependency becomes your latency. In an event-driven system, it becomes that dependency’s problem to catch up.

Independent Scaling

Consumers scale based on their own throughput requirements. If fraud detection is compute-heavy and needs 20 instances to keep up with peak order volume, it scales independently without affecting the order service or the shipping service. The message broker holds the backlog; each consumer works through it at its own pace.

An Audit Trail by Default

Every event is a durable record of what happened and when. This is the foundation of event sourcing (though EDA does not require it). When a customer asks why their order was cancelled, you have a complete timeline: OrderPlaced, PaymentAttempted, PaymentFailed, OrderCancelled. You did not have to build a separate audit log. The event stream is the audit log.

Adding Consumers Without Changing Producers

A new business requirement arrives: when an order ships, send the data to the new warehouse management system. In a synchronous architecture, the shipping service gets a new outbound HTTP call. In an event-driven one, the WMS team subscribes to OrderShipped and the shipping service never changes. This is real decoupling, and it compounds over time.

The Real Costs

Eventual Consistency

This is not a warning to wave at junior engineers. It is a continuous engineering burden.

When OrderPlaced is published and the inventory service processes it asynchronously, there is a window where the order exists but inventory has not been decremented. In most cases, this window is milliseconds. In cases of consumer lag, outages, or replay operations, it can be minutes or hours.

You have to design every read path knowing that the data may be stale. You have to make product decisions about what “stale” means in each context. Can a user see their order as “confirmed” before payment has been verified? Can you show an item as “in stock” when the inventory consumer is 30 seconds behind? These are not engineering questions. They are product questions, and they have to be answered explicitly.

If your product genuinely requires strong consistency, for example, double-spend prevention in a financial transaction, synchronous coordination with distributed locks or database transactions is almost certainly the right answer. EDA will not simplify this; it will make it harder.

Debugging Distributed Flows

In a synchronous system, a request has a call stack. You can trace it. In an event-driven system, a user action triggers an event, which triggers three consumers, one of which publishes another event that triggers two more consumers. When something breaks, the failure surface is wide and the causality chain requires distributed tracing to reconstruct.

Without correlation IDs threaded through every event and every log line, debugging production incidents becomes an exercise in reading message broker timestamps and guessing. This is not insurmountable, but it requires investment. You need distributed tracing, structured logging, and ideally a way to replay individual events through individual consumers in a test environment.

type EventEnvelope<T> = {
  eventId: string;        // UUID, unique per event instance
  correlationId: string;  // Threads through a causal chain
  causationId: string;    // The eventId that caused this one
  type: string;
  occurredAt: string;     // ISO 8601
  payload: T;
};

function publishEvent<T>(
  type: string,
  payload: T,
  cause?: EventEnvelope<unknown>
): EventEnvelope<T> {
  return {
    eventId: crypto.randomUUID(),
    correlationId: cause?.correlationId ?? crypto.randomUUID(),
    causationId: cause?.eventId ?? "",
    type,
    occurredAt: new Date().toISOString(),
    payload,
  };
}

correlationId follows the original trigger across the entire causal chain. causationId records the direct parent event. With these two fields, you can reconstruct any event graph from your log storage.

Schema Evolution Burden

Every event is a contract between a producer and every consumer. When the producer needs to add a field, rename a field, or change a type, every consumer is affected. In a synchronous API, you version the endpoint and migrate clients on a known schedule. With events, especially in systems with long-lived consumers or event replay, you have to maintain backward compatibility across versions of consumers that may be running simultaneously.

This is manageable with discipline, but it requires a schema registry, a compatibility policy, and tooling to enforce it in CI. The operational overhead is real. If your team is small and moving fast, this overhead may not be worth it for internal service communication where you control both sides.

Schema evolution in event-driven systems is a topic deep enough to deserve its own treatment. The short version: use explicit versioning, never remove or rename fields without a deprecation period, and test consumer compatibility in your deployment pipeline.

The Overhead of Infrastructure

A synchronous HTTP call between two services has no additional infrastructure cost. An event-driven system requires a message broker (or equivalent), dead-letter queues, consumer group management, offset tracking, and monitoring for consumer lag. That is a meaningful operational surface to manage.

Decision Criteria: When to Use EDA

Use event-driven architecture when:

The business process is genuinely asynchronous. Order fulfillment, document processing, email delivery, data synchronization, report generation. These do not have a human waiting for a response within milliseconds.

Multiple consumers react to the same fact. If placing an order should trigger inventory, notifications, analytics, and fraud checks, EDA is natural. Adding a new reaction does not require touching the producer.

You need temporal decoupling for resilience. If the downstream service is less reliable than the upstream one, buffering events protects the upstream from the downstream’s availability.

The flow spans organizational boundaries. When teams own different services with different release cadences, event contracts are a cleaner boundary than synchronous API contracts that require coordinated deployments.

You need a durable record of what happened. Event streams give you audit, replay, and the ability to build new projections from historical data.

Decision Criteria: When to Stay Synchronous

Use synchronous request-response when:

The user is waiting for the result. Login, checkout confirmation, search. If the answer has to come back before the user sees the next screen, EDA introduces latency and complexity with no benefit.

Strong consistency is required. Seat reservations, financial transfers, inventory decrement at the point of sale. Eventual consistency is a liability here, not a feature.

There is exactly one consumer. If service A calls service B and only service B ever processes that data, the indirection of a message broker adds operational cost with no decoupling benefit. A direct HTTP call with a retry policy is simpler and easier to reason about.

The team is small and moving fast. EDA has a higher floor of operational maturity. A two-person team building an MVP will be faster and more reliable with a well-structured monolith or a simple REST API mesh than with a distributed event system.

You would be introducing EDA to avoid fixing a coupling problem. “Let’s use events” is not a solution for a poorly designed data model or an unclear ownership boundary. Fix the coupling first.

Common Anti-Patterns

Event Soup

Every state change in every service publishes an event to a shared broker. Services subscribe to each other’s events freely. After six months, no one knows which services depend on which events, removing any event is a production incident waiting to happen, and the “decoupled” architecture is actually a tightly coupled distributed monolith where the coupling is just harder to see.

The fix: treat events as public APIs. Apply the same review and deprecation discipline you would to an HTTP endpoint. Not every internal state change needs to be an event on a shared bus.

God Events

// Do not do this
type OrderEvent = {
  type: "ORDER_UPDATED";
  orderId: string;
  status: string;
  items: LineItem[];
  customer: Customer;
  payment: PaymentDetails;
  shipping: ShippingDetails;
  metadata: Record<string, unknown>;
};

A single event type that represents every possible change to an entity. Consumers have to inspect the payload to determine what actually changed, and every consumer receives every update even when it only cares about one field.

Use specific, fine-grained events: OrderPaymentConfirmed, OrderShipped, OrderCancelled. Each one means exactly one thing. Consumers subscribe to what they actually need.

Hidden Coupling Through Shared Event Stores

When multiple services read from and write to the same event store tables or topics without a clear ownership model, you have recreated the shared database coupling problem in a different form. Events that cross a domain boundary should be explicit contracts. Events internal to a domain should stay internal.

Choreography All the Way Down

Pure choreography, where every service reacts to events from other services with no central coordinator, works well for simple flows. For complex business processes with branching logic, compensation, and timeouts, choreography becomes impossible to reason about. When OrderPlaced triggers five services and one of them fails, how does the system know to compensate? How does a new engineer understand what is supposed to happen?

For complex flows, a process manager or saga coordinator that handles the state machine explicitly is easier to test, easier to debug, and easier to change.

Practical Implementation in TypeScript

Here is a minimal, production-viable pattern for a typed event bus with dead-letter handling:

type EventHandler<T> = (event: EventEnvelope<T>) => Promise<void>;

type HandlerRegistration = {
  eventType: string;
  handler: EventHandler<unknown>;
  maxRetries: number;
};

class LocalEventBus {
  private handlers = new Map<string, HandlerRegistration[]>();
  private dlq: Array<{ event: EventEnvelope<unknown>; error: Error }> = [];

  subscribe<T>(
    eventType: string,
    handler: EventHandler<T>,
    maxRetries = 3
  ): void {
    const existing = this.handlers.get(eventType) ?? [];
    existing.push({
      eventType,
      handler: handler as EventHandler<unknown>,
      maxRetries,
    });
    this.handlers.set(eventType, existing);
  }

  async publish<T>(event: EventEnvelope<T>): Promise<void> {
    const registrations = this.handlers.get(event.type) ?? [];

    await Promise.allSettled(
      registrations.map((reg) => this.invokeWithRetry(event, reg))
    );
  }

  private async invokeWithRetry(
    event: EventEnvelope<unknown>,
    reg: HandlerRegistration
  ): Promise<void> {
    let attempt = 0;
    while (attempt <= reg.maxRetries) {
      try {
        await reg.handler(event);
        return;
      } catch (err) {
        attempt++;
        if (attempt > reg.maxRetries) {
          this.dlq.push({ event, error: err as Error });
          console.error(
            `[DLQ] Handler for ${event.type} failed after ${reg.maxRetries} retries`,
            { eventId: event.eventId, error: (err as Error).message }
          );
        } else {
          const backoffMs = Math.min(100 * 2 ** attempt, 5000);
          await new Promise((resolve) => setTimeout(resolve, backoffMs));
        }
      }
    }
  }

  getDLQ() {
    return [...this.dlq];
  }
}

In production, replace the in-process bus with a durable broker. The handler registration pattern stays the same; the transport changes. The dead-letter queue becomes a real topic or table that an operator can inspect and replay from.

For outbound events that must survive process crashes, use the transactional outbox pattern: write the event to a database table in the same transaction as your domain state change, then have a separate process tail that table and publish to the broker. This eliminates the dual-write consistency problem where the database write succeeds but the event publish fails.

// In your domain transaction
async function placeOrder(
  db: DatabaseTransaction,
  order: Order
): Promise<void> {
  await db.insert("orders", order);

  // Written atomically with the order — no lost events
  await db.insert("outbox_events", {
    id: crypto.randomUUID(),
    aggregateId: order.id,
    eventType: "ORDER_PLACED",
    payload: JSON.stringify(order),
    createdAt: new Date().toISOString(),
    publishedAt: null,
  });
}

A relay process polls outbox_events where publishedAt IS NULL, publishes to the broker, then marks the row as published. Idempotency keys on the consumer side handle any duplicates from relay retries.

Tradeoffs at a Glance

ConcernSynchronousEvent-Driven
LatencyLow, predictableHigher, variable
ConsistencyStrong by defaultEventual, requires design
DebuggingCall stack, straightforwardDistributed tracing required
CouplingTemporal (availability dependency)Schema (contract dependency)
ScalingCoordinatedIndependent per consumer
New consumersProducer change requiredSubscribe and deploy
Operational surfaceLowHigh (broker, DLQ, lag monitoring)
AuditabilityRequires explicit loggingEvent stream is the log

Neither column is the winner. The right answer depends on the flow, the team, and the consistency requirements.

Production Considerations

Consumer lag is a production signal. Instrument lag per consumer group and alert when it grows. A consumer that is falling behind is either under-resourced or encountering errors it is silently swallowing.

Idempotency is not optional. Message brokers deliver at-least-once. Your consumers will receive duplicates. Every handler must be safe to invoke multiple times with the same event. The simplest mechanism is tracking processed eventId values in a database table with a unique constraint.

Order guarantees are narrower than you think. Most brokers guarantee order within a partition, not globally. If your business logic requires a strict ordering between OrderPlaced and OrderShipped for the same order, ensure they land in the same partition, keyed by order ID.

Schema changes are deployments. Adding a field to an event is a producer deployment followed by consumer deployments. Plan the sequence. Add the field as optional, deploy the producer, then deploy consumers that use the new field. Never remove a field until all consumers have been updated and the change has been observed in production.

Test consumer behavior in isolation. Each consumer should be testable by injecting an event envelope directly, without needing the full broker stack. This makes unit testing fast and makes reproducing production failures straightforward: capture the envelope from the DLQ, inject it into the test harness, fix the bug.

The Honest Assessment

EDA is not a default. It is a deliberate choice that trades operational simplicity for scalability, resilience, and decoupling. Those benefits are real, but they require investment in observability, schema discipline, and consumer design that a synchronous architecture does not.

The teams that use EDA well treat events as public API contracts with versioning and deprecation policies. They instrument consumer lag, correlation IDs, and DLQ depth as first-class production signals. They use choreography for simple flows and explicit process managers for complex ones. They do not reach for EDA to solve a coupling problem that should be solved in the domain model.

The teams that struggle with it publish every internal state change as an event, skip the correlation IDs, and end up with a system that is harder to debug than the monolith they replaced. The pattern is not the problem. The discipline is.

Start synchronous. Introduce events where you have a clear answer to “what are the consumers?” and “what happens when a consumer is down?” If those questions are hard to answer, the boundary is not ready for events yet.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.