Skip to content

Event-Driven Architecture: Choreography, Orchestration & Sourcing

Core Concept LearningJuly 4, 20265 min readUpdated July 21, 2026

Checkout hangs because payment is slow — and the order service is blocked waiting on a synchronous call. Event-driven architecture breaks that chain: a service publishes what happened, and interested services react independently.

The cost is eventual consistency, duplicate delivery, and debugging across async boundaries. This article maps choreography vs orchestration, sagas, the outbox pattern, and event sourcing — with one checkout event trace and the failure paths that make those patterns necessary.

Compare the storage responsibilities of Event Sourcing and CQRS. Then choose transport semantics with the Kafka, RabbitMQ, and SQS comparison.

A publisher fans out one event to independent consumers
A publisher fans out one event to independent consumers

Events vs Commands vs Queries

Not every message is the same. An event is a fact that already happened: OrderPlaced, PaymentProcessed, InventoryReserved — past tense, immutable, zero to many consumers. A command asks one receiver to do work: PlaceOrder, ProcessPayment — and expects success or failure. A query reads state without changing it.

Checkout event trace (happy path): Client → Order API saves a pending order → publishes order.placed → Inventory reserves stock → publishes inventory.reserved → Payment charges → publishes payment.processed → Notification emails the customer. Order never imports Inventory or Payment clients; it only emits facts. Use commands inside a bounded context; publish domain events across service boundaries.

Quick reference

  • Event: past-tense fact; broadcast; publishers do not know consumers.
  • Command: imperative; one intended receiver; success/failure response.
  • Query: read-only; no side effects.
  • Domain event: meaningful to the business (OrderPlaced), not DatabaseRowInserted.
  • Integration event: shared across services, usually via a broker.
  • CloudEvents-style envelope: source, type, id, time, data — aids routing and dedupe.

Remember this

Publish events for cross-service communication; keep commands inside a bounded context where coupling is acceptable.

Choreography vs Orchestration

Two coordination styles. Choreography: each service reacts to events and publishes the next — no central controller; the workflow emerges from the chain. Orchestration: a saga coordinator or workflow engine tells each service what to do and collects results.

Choreography scales and has no single controller SPOF, but the full workflow lives across N services and N topics. Orchestration is visible in one place, but the orchestrator becomes a coupling and failure point. Start with choreography for short chains; add orchestration when rollback and visibility outgrow tribal knowledge.

Choreography has no controller; orchestration has one coordinator
Choreography has no controller; orchestration has one coordinator

Quick reference

  • Choreography: loose coupling, hard to visualize the full workflow.
  • Orchestration: visible workflow; orchestrator is a coupling point.
  • Orchestration tools: Temporal, AWS Step Functions, Azure Durable Functions, Conductor.
  • Escalate from choreography when chains exceed ~3 steps or need complex rollback.
  • Both styles require idempotent consumers — brokers deliver at-least-once.
Orchestration — sync calls couple every hop
1// Order service owns the flow — every failure needs local undo logic2async function placeOrder(order: Order) {3  await inventoryService.reserve(order.items); // fails? order fails4  try {5    await paymentService.charge(order.total);6  } catch (err) {7    await inventoryService.release(order.items); // compensate8    throw err;9  }10  await notificationService.sendConfirmation(order);11  await db.save({ ...order, status: "confirmed" });12}
Choreography — publish facts; others react
1async function placeOrder(order: Order) {2  await db.save({ ...order, status: "pending" });3  await eventBus.publish("order.placed", {4    eventId: crypto.randomUUID(),5    orderId: order.id,6    items: order.items,7    total: order.total,8    customerId: order.customerId,9  });10}11 12eventBus.subscribe("order.placed", async (event) => {13  await reserveStock(event.items);14  await eventBus.publish("inventory.reserved", { orderId: event.orderId, eventId: crypto.randomUUID() });15});16 17eventBus.subscribe("inventory.reserved", async (event) => {18  await chargeCustomer(event.orderId);19  await eventBus.publish("payment.processed", { orderId: event.orderId, eventId: crypto.randomUUID() });20});

Remember this

Start with choreography for simple chains; add orchestration when you need one place to see and compensate multi-step work.

The Saga Pattern for Distributed Transactions

Two-phase commit holds locks across services and fails awkwardly when a coordinator crashes. A saga is a sequence of local transactions; if a step fails, compensating transactions undo prior steps. Example: reserve inventory → charge payment → confirm. If payment fails, release the reservation and notify the customer.

Failure path: Payment returns card_declined → Payment publishes payment.failed → Inventory compensates with inventory.released → Order marks cancelled. Sagas give eventual consistency, not ACID isolation — concurrent sagas can see intermediate state.

Saga: local steps in sequence — compensate on failure
Saga: local steps in sequence — compensate on failure

Quick reference

  • Each step is a local transaction; cross-service consistency is eventual.
  • Compensation is domain logic — not always a simple undo.
  • Choreography saga: success/failure events drive next step or compensate.
  • Orchestration saga: coordinator tracks state and dispatches commands.
  • Pivot step: after which compensation is impossible (e.g. goods shipped) — design explicitly.
  • Idempotency keys on every event are mandatory under retries.
Choreography saga — compensate on payment.failed
1eventBus.subscribe("payment.failed", async (event) => {2  // Idempotent: release only if reservation still exists3  const reservation = await inventory.findOpen(event.orderId);4  if (!reservation) return; // already compensated or never reserved5  await inventory.release(reservation.id);6  await eventBus.publish("inventory.released", {7    orderId: event.orderId,8    eventId: crypto.randomUUID(),9    reason: event.reason ?? "payment_failed",10  });11});
Order reacts — terminal cancelled state
1eventBus.subscribe("inventory.released", async (event) => {2  await db.orders.update(3    { id: event.orderId },4    { status: "cancelled", cancelReason: event.reason }5  );6  await eventBus.publish("order.cancelled", {7    orderId: event.orderId,8    eventId: crypto.randomUUID(),9  });10});

Remember this

Sagas replace distributed locks with local transactions plus compensations — accept eventual consistency for availability.

The Outbox Pattern: Reliable Event Publishing

Save-then-publish is not atomic: crash after DB commit and the event never leaves; publish-then-save and consumers act on work that rolled back. The outbox writes the business row and the outgoing event in one transaction; a relay publishes to the broker and marks rows sent.

When not to skip it: any path where lost or duplicate-wrong events corrupt money, inventory, or compliance. Polling relays and CDC (e.g. Debezium on Postgres WAL) both work; consumers must still dedupe by eventId.

One outbox event: publish succeeds, mark-sent fails, then relay retries
One outbox event: publish succeeds, mark-sent fails, then relay retries

Quick reference

  • Relay is at-least-once — consumers must be idempotent.
  • Poll the outbox table or use CDC on the WAL to avoid busy polling.
  • Put a unique event id in the payload for consumer dedupe.
  • Delete or archive old outbox rows after confirmed delivery.
  • Standard companion to CQRS and event sourcing write paths.
Dangerous — two writes, not atomic
1async function placeOrder(order: Order) {2  await db.orders.insert(order);3  // Crash here → order exists, event never published4  await kafka.publish("order.placed", order);5}
Outbox — atomic write; relay publishes
1async function placeOrder(order: Order) {2  await db.transaction(async (tx) => {3    await tx.orders.insert(order);4    await tx.outbox.insert({5      id: crypto.randomUUID(),6      topic: "order.placed",7      payload: JSON.stringify(order),8      sentAt: null,9    });10  });11}12 13async function outboxRelay() {14  const pending = await db.outbox.findAll({ where: { sentAt: null }, limit: 100 });15  for (const event of pending) {16    try {17      await kafka.publish(event.topic, JSON.parse(event.payload));18      await db.outbox.update({ sentAt: new Date() }, { where: { id: event.id } });19    } catch (err) {20      // Leave sentAt null — next poll retries (at-least-once)21      logger.error({ err, eventId: event.id }, "outbox publish failed");22    }23  }24}

Remember this

Commit business state and the outbox row atomically; publish to the broker separately with retries and consumer deduplication.

Event Sourcing

Traditional persistence stores current state. Event sourcing stores every change as an immutable event; current state is a projection of the stream. An account balance is the fold of deposits and withdrawals, not a single mutable column.

You gain an audit log and point-in-time reconstruction. You pay with projection complexity, eventual consistency, and snapshots once streams grow. When not to use: simple CRUD, heavy ad-hoc reporting without a separate read model, or a team that cannot operate eventual consistency. Prefer a normal table plus outbox unless you need the audit/time-travel properties themselves.

Quick reference

  • Event store: append-only log (EventStoreDB, Marten on Postgres, etc.).
  • Projection: read model rebuilt or updated from events.
  • Snapshot: checkpoint aggregate state so replay does not start at event 1.
  • Pairs naturally with CQRS (write = events, read = projections).
  • Use when: audit, finance-like domains, time-travel debugging.
  • Skip when: CRUD apps, teams new to eventual consistency, query-first workloads.

Remember this

Event sourcing earns its complexity only when you need the audit trail or reconstruction — not as a default microservice style.

Key takeaway

Event-driven architecture trades sync simplicity for decoupling and resilience. Outbox makes publish reliable; sagas make partial failure recoverable; choreography keeps short chains simple; orchestration and event sourcing are opt-in when visibility or audit demand them.

Practice (25 min): Trace one checkout: list every event name, producer, and consumer. Inject a payment failure after inventory reserved — write the compensating handler and prove a duplicate payment.failed delivery is a no-op. Confirm the order ends cancelled and inventory is free.

Share:

Related Articles

Tight coupling in REST and gRPC microservice architectures creates cascading service failures. If an Order Service calls

Read

Decoupling microservices using Event-Driven Architecture (EDA) requires choosing an asynchronous messaging backbone. Eng

Read

A customer sees “payment pending” after checkout, retries, and is charged twice. The design question is not whether the

Read

Keep learning

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