EventsMessagingCQRS

Event-Driven Architecture: Patterns and Pitfalls

13 min read
Event-driven architecture (EDA) enables systems where components communicate through events rather than direct calls. This decoupling improves resilience, scalability, and extensibility — but introduces challenges around ordering, idempotency, and eventual consistency. This guide covers the core patterns, when to apply them, and the operational concerns you need to address.

Events, Commands, and Queries

In EDA, an event is an immutable record of something that happened: OrderPlaced, UserRegistered, PaymentProcessed. Events are facts — they describe past state changes and cannot be rejected by consumers. Commands are requests to perform an action (PlaceOrder, RegisterUser) that may be accepted or rejected. Queries read current state without side effects. Keeping these three concepts distinct is fundamental to a clean event-driven design.

info

Name events in past tense (OrderPlaced, not OrderPlace or PlaceOrder). This reinforces that events are facts about things that already happened, not requests.

Publish-Subscribe Pattern

The simplest EDA pattern is publish-subscribe (pub/sub). A producer publishes events to a topic or channel. Multiple consumers subscribe to that topic and process events independently. Producers don't know about consumers, and adding new consumers doesn't require changing the producer. This pattern is ideal for fan-out scenarios like sending notifications, updating search indexes, and generating analytics — all triggered by a single business event.

events/order-events.ts
interface OrderPlacedEvent {
  type: "OrderPlaced"
  orderId: string
  customerId: string
  items: OrderItem[]
  total: number
  occurredAt: string
}

// Producer: publishes after order is saved
await eventBus.publish("orders", orderPlacedEvent)

// Consumer 1: sends confirmation email
// Consumer 2: updates inventory counts
// Consumer 3: triggers fraud detection analysis

Event Sourcing

Event sourcing takes EDA further by making the event log the primary source of truth. Instead of storing current state in a database and losing history, you store the complete sequence of events that led to the current state. Current state is derived by replaying events from the beginning (or from a snapshot). This provides a complete audit trail, the ability to reconstruct state at any point in time, and natural support for temporal queries.

warning

Event sourcing adds significant complexity. Use it only when the audit trail, temporal queries, or replay capabilities provide concrete business value — typically in financial systems, compliance-heavy domains, or collaborative editing.

Idempotency: The Non-Negotiable Requirement

In any distributed event system, messages can be delivered more than once due to network retries, consumer restarts, or broker redelivery. Every event consumer must be idempotent — processing the same event twice must produce the same result as processing it once. Common strategies include using a unique event ID and tracking processed IDs in a deduplication table, designing operations to be naturally idempotent (e.g., SET status = 'shipped' is idempotent, INCREMENT counter is not), and using database constraints to prevent duplicate side effects.

  • Always include a unique eventId in every event payload
  • Track processed event IDs in a deduplication store with TTL
  • Prefer idempotent database operations (upserts over inserts)
  • Test idempotency explicitly: publish the same event twice and verify correct behavior