Skip to content
Back to blog
Event-DrivenMicroservicesArchitectureKafkaEvent Sourcing

Event-Driven Architecture: Choreography, Orchestration, and Event Sourcing

July 4, 202615 min read

Synchronous microservices create invisible chains: when the payment service is slow, the order service is slow, and when the email service is down, orders fail. Event-driven architecture breaks these chains by letting services communicate through events instead of direct calls — a service publishes what happened, and any interested service reacts to it independently.

But event-driven systems introduce their own complexity: eventual consistency, out-of-order delivery, duplicate events, and debugging across asynchronous boundaries. This article covers the patterns you need to use event-driven architecture correctly — the difference between choreography and orchestration, event sourcing, the outbox pattern, and the saga pattern for distributed transactions.

Events vs Commands vs Queries

Not every message is the same. Getting the distinction right shapes the design of your entire system.

An event is a record of something that happened: OrderPlaced, PaymentProcessed, InventoryReserved. Events are facts — immutable, in past tense, broadcast to anyone interested. Publishers don't know or care who consumes them. A command is a request to do something: PlaceOrder, ProcessPayment. Commands have one intended recipient and expect a response (success or failure). A query asks for data: GetOrder, ListProducts. Queries don't change state.

This distinction matters because events enable decoupling: the order service doesn't know the inventory service exists — it just publishes OrderPlaced and the inventory service subscribes. Commands maintain coupling: the order service must know the inventory service exists to call it.

Quick reference

  • Event: 'Something happened' — past tense, immutable fact. Zero to many consumers.
  • Command: 'Please do this' — imperative, one intended receiver, expects success/failure response.
  • Query: 'Tell me about this' — read-only, no side effects.
  • Domain event: event that is meaningful to the business domain (OrderPlaced, not DatabaseRowInserted).
  • Integration event: event shared across service boundaries, usually via a message broker.
  • Use CloudEvents spec for a standard event envelope (source, type, id, time, data).

Remember this

Publish events (facts) for cross-service communication. Use commands only within a bounded context where coupling is acceptable.

Choreography vs Orchestration

Two styles govern how services coordinate in an event-driven system. Choreography: each service knows what events to react to and what events to publish next. There is no central controller. The workflow emerges from the chain of reactions. Orchestration: a central orchestrator (a saga coordinator or workflow engine) explicitly tells each service what to do and collects results.

Choreography scales well and has no single point of failure, but the overall workflow is invisible — it's spread across N services and N topics. When something goes wrong, you debug across logs from multiple services. Orchestration makes the workflow visible and traceable in one place, but the orchestrator is a bottleneck and a coupling point. Most teams start with choreography and add orchestration when the workflow becomes too complex to reason about.

Quick reference

  • Choreography: loose coupling, high scalability, hard to visualize full workflow.
  • Orchestration: visible workflow, easier debugging, introduces an orchestrator as a coupling point.
  • Tools for orchestration: Temporal, AWS Step Functions, Azure Durable Functions, Conductor.
  • Use choreography for simple event chains (3 steps). Escalate to orchestration for complex long-running workflows.
  • Both patterns require idempotency: a service may receive the same event twice and must handle it safely.
Before
Orchestration — Order service controls the flow directly
1// Order service orchestrates everything synchronously2async function placeOrder(order: Order) {3  // Tight coupling to every downstream service4  await inventoryService.reserve(order.items);       // fails? order fails5  await paymentService.charge(order.total);          // fails? must un-reserve6  await notificationService.sendConfirmation(order); // fails? must un-charge7  await db.save({ ...order, status: "confirmed" });8}
After
Choreography — Order service publishes, others react
1// Order service only publishes — no knowledge of downstream services2async function placeOrder(order: Order) {3  await db.save({ ...order, status: "pending" });4  await eventBus.publish("order.placed", {5    orderId: order.id,6    items: order.items,7    total: order.total,8    customerId: order.customerId,9  });10  // Done. Inventory, payment, notification subscribe independently.11}12 13// Inventory service — reacts to order.placed14eventBus.subscribe("order.placed", async (event) => {15  await reserveStock(event.items);16  await eventBus.publish("inventory.reserved", { orderId: event.orderId });17});18 19// Payment service — reacts to inventory.reserved20eventBus.subscribe("inventory.reserved", async (event) => {21  await chargeCustomer(event.orderId);22  await eventBus.publish("payment.processed", { orderId: event.orderId });23});

Remember this

Start with choreography for simplicity. Add orchestration when you need visibility into multi-step workflows or complex rollback logic.

The Saga Pattern for Distributed Transactions

Distributed transactions (2PC — two-phase commit) don't work well at scale: they hold locks across services, are slow, and fail in complex ways when a coordinator crashes. The saga pattern is the alternative: a long-running process broken into a sequence of local transactions, each publishing an event. If any step fails, compensating transactions run in reverse to undo prior steps.

An order saga might be: reserve inventory → charge payment → send confirmation. If payment fails, the compensation reverses inventory: release reservation → send failure notification. Sagas don't give you ACID isolation (concurrent sagas can see each other's intermediate state), but they give you eventual consistency without distributed locks.

Quick reference

  • Each saga step is a local transaction — atomic within one service, consistent across services only eventually.
  • Compensating transaction: the reverse of a step. Not always a simple undo — may require domain logic.
  • Choreography saga: each service publishes success/failure events that trigger the next step or compensation.
  • Orchestration saga: a saga coordinator explicitly tracks state and dispatches commands to each participant.
  • Pivot transaction: the saga step after which compensation becomes impossible (e.g., payment charged and delivered). Design carefully.
  • Idempotency is mandatory: retried saga steps must produce the same result as the first execution.

Remember this

Sagas replace distributed transactions with a chain of local transactions and compensating actions. They accept eventual consistency in exchange for scalability and availability.

The Outbox Pattern: Reliable Event Publishing

Publishing an event and saving to the database in the same operation is harder than it looks. If you save to the database and then the process crashes before publishing, the event is lost. If you publish the event first and then the database save fails, consumers act on a transaction that never completed.

The outbox pattern solves this with a single atomic operation: write both the business record and the outgoing event to the same database transaction. A separate relay process reads the outbox table and publishes events to the message broker, then marks them as sent. The business write and the event are guaranteed to be consistent because they're committed atomically.

Quick reference

  • The relay process is at-least-once: events may be published more than once. Consumers must be idempotent.
  • Implement relay with polling or change data capture (Debezium on Postgres WAL — zero-polling overhead).
  • Mark events with a unique event ID (idempotency key) so consumers can deduplicate.
  • Clean up old outbox rows to prevent table bloat — delete after N days or after confirmed delivery.
  • Transactional outbox is the standard pattern in CQRS and event sourcing architectures.
Before
Dangerous — two separate writes, not atomic
1async function placeOrder(order: Order) {2  await db.orders.insert(order);3  // Process crashes here → event never sent, order exists without notification4  await kafka.publish("order.placed", order);5}
After
Outbox — single atomic transaction, relay publishes
1// Step 1: Atomic write — order + outbox event in one transaction2async function placeOrder(order: Order) {3  await db.transaction(async (tx) => {4    await tx.orders.insert(order);5    await tx.outbox.insert({6      id: crypto.randomUUID(),7      topic: "order.placed",8      payload: JSON.stringify(order),9      sentAt: null,10    });11    // Both committed or both rolled back — no split-brain12  });13}14 15// Step 2: Relay process (runs separately, retries on failure)16async function outboxRelay() {17  const pending = await db.outbox.findAll({ where: { sentAt: null } });18  for (const event of pending) {19    await kafka.publish(event.topic, JSON.parse(event.payload));20    await db.outbox.update({ sentAt: new Date() }, { where: { id: event.id } });21  }22}

Remember this

Never write to the database and publish an event in two separate operations. The outbox pattern makes event publishing atomic with your business transaction.

Event Sourcing

Traditional persistence stores the current state of an entity. Event sourcing stores every change as an immutable event. The current state is derived by replaying all events from the beginning. An account with three deposits and one withdrawal isn't stored as a single balance — it's stored as four events, and the balance is computed from them.

This gives you a complete audit log, the ability to reconstruct state at any past point in time, and a natural fit for event-driven systems because the event store is the source of truth. The trade-off is complexity: queries need read models (projections) rebuilt from events, eventual consistency is inherent, and replaying thousands of events per request requires snapshots.

Quick reference

  • Event store: append-only log of immutable domain events. EventStoreDB and Marten (Postgres) are common choices.
  • Projection: a read model built by processing events. Replayed from the event log on startup or incrementally.
  • Snapshot: a checkpoint of the aggregate state at event N. Replay resumes from the snapshot, not event 1.
  • CQRS pair: event sourcing naturally separates the write model (event log) from the read model (projection).
  • When to use: audit requirements, financial systems, complex domain logic, 'time travel' debugging needs.
  • When NOT to use: simple CRUD apps, heavy query patterns, teams unfamiliar with eventual consistency.

Remember this

Event sourcing gives you a perfect audit log and time-travel debugging at the cost of query complexity. It pairs naturally with CQRS. Don't use it unless you need one of its specific benefits.

Key takeaway

Share:

Event-driven architecture is not a silver bullet — it's a trade. You gain scalability, resilience, and decoupling. You give up simplicity, synchronous consistency, and easy debuggability. The patterns in this article — outbox for reliable publishing, sagas for distributed transactions, choreography for simple chains, orchestration for complex workflows — all exist to make that trade manageable.

Start with the outbox pattern on day one: reliable event publishing is non-negotiable. Add the saga pattern when you have multi-step processes that can partially fail. Add orchestration when choreography becomes too hard to reason about. Add event sourcing only when you genuinely need a complete audit log or time-travel debugging — not just because it sounds interesting.

Related Articles

Event SourcingCQRS

Event Sourcing and CQRS are two patterns that often appear together in microservices and domain-driven design — but they

Read

Every new project faces the same question: one big application or many small services? The answer is rarely binary. A mo

Read

Asynchronous messaging decouples services in time — producers send messages without waiting for consumers. But not all m

Read

Keep learning

Follow a structured path or browse all courses to go deeper.