Arrange Act Assert

Jag Reehals thinking on things, mostly product development

Understanding Commands & Events with Orchestration & Choreography

18 Jun 2025

Designing distributed systems has never been more challenging. As teams embrace microservices and event‑driven architectures, a persistent myth has arisen: commands equal orchestration, and events equal choreography. This tidy equivalence often becomes a mental shortcut but conceals the deeper truth of control‑flow patterns versus messaging semantics. As many practitioners have observed, collapsing these separate dimensions can restrict your system's flexibility and resilience.

Person thinking about the difference between commands and events.

In this article, we'll demystify these concepts and show how to apply them independently. You'll discover how separating semantics (commands vs. events) from control flow (orchestration vs. choreography) grants you greater architectural freedom and clearer, more maintainable workflows.

Let's examine three core scenarios, highlighting error handling, delivery guarantees and state‑management nuances.

1. Orchestration with Events: Centralised, Resumable Workflows

An orchestrator maintains end‑to‑end visibility by subscribing to domain events, tracking each workflow's state, and emitting further actions. Although illustrated with events, the same pattern applies to commands.

// orchestrator.ts
interface OrderPlaced {
  orderId: string;
}
interface PaymentConfirmed {
  orderId: string;
}

// EventBus and StateStore interfaces for DI
interface EventBus {
  emit(type: string, payload: any): Promise<void>;
}
interface StateStore {
  get(orderId: string): string | undefined;
  set(orderId: string, state: string): void;
}

class OrderOrchestrator {
  constructor(
    private eventBus: EventBus, // Injected event bus
    private state: StateStore // Injected state store
  ) {}

  async handle(event: OrderPlaced) {
    this.state.set(event.orderId, 'payment_pending');
    console.log(`Order ${event.orderId} placed. Starting payment.`);
    await this.eventBus.emit('ProcessPayment', { orderId: event.orderId });
  }

  async onPaymentConfirmed(event: PaymentConfirmed) {
    this.state.set(event.orderId, 'fulfilled');
    console.log(`Payment confirmed for ${event.orderId}. Fulfilling order.`);
    await this.eventBus.emit('FulfilOrder', { orderId: event.orderId });
  }
}

Error Handling: The orchestrator can centrally retry failed steps or invoke compensating actions. If a step fails, the workflow pauses and retries based on configured policies.

Delivery Guarantees: Events often default to at‑most‑once delivery on basic buses, though modern platforms (Kafka, Pulsar) provide at‑least‑once semantics with idempotent consumers to avoid data loss. The orchestrator persists all incoming events and uses idempotent handlers to ensure each workflow step runs exactly once or is safely retried.

2. Choreography with Commands: Decentralised, Implicit Flows

In a choreographed system, each service reacts locally and issues the next command. There is no central coordinator—each participant knows only its own scope.

// inventory.service.ts
// CommandBus interface for DI
interface CommandBus {
  send(type: string, payload: any): Promise<void>;
}

class InventoryService {
  constructor(private commandBus: CommandBus) {} // Injected command bus

  async reserveStock(orderId: string, sku: string) {
    console.log(`Inventory: Reserving ${sku} for order ${orderId}.`);
    await this.commandBus.send('ScheduleShipment', { orderId, sku });
  }
}
// shipping.service.ts
class ShippingService {
  constructor(private commandBus: CommandBus) {} // Injected command bus

  async scheduleShipment(orderId: string, sku: string) {
    console.log(`Shipping: Scheduling shipment for ${orderId} (${sku}).`);
    // ... further logic ...
  }
}

Error Handling: Each service can route unprocessable messages to a dead‑letter queue for later inspection, preventing a single failure from halting the entire choreography.

Delivery Guarantees: Commands generally require at‑least‑once delivery (e.g. via durable queues), so idempotent handlers are essential. Events in choreographies may default to at‑most‑once delivery, but real‑world event streaming often guarantees at‑least‑once with idempotent consumers.

3. Mixed Messaging: Orthogonal Dimensions in Practice

By decoupling semantics from control flow, you can mix commands and events as appropriate.

// payment-orchestrator.ts
class PaymentOrchestrator {
  constructor(private commandBus: CommandBus) {} // Injected command bus

  async handleCommand(cmd: { type: 'ProcessPayment'; orderId: string }) {
    console.log(`Processing payment for ${cmd.orderId}.`);
    if (await this.needsCharge(cmd.orderId)) {
      await this.commandBus.send('AttemptCharge', cmd);
    }
  }

  private async needsCharge(orderId: string): Promise<boolean> {
    // ... check if charge is needed ...
    return true;
  }
}

// shipping-subscriber.ts
// EventBus and CommandBus interfaces reused
function subscribeEvent<T>(
  eventType: string,
  handler: (event: T) => Promise<void>
) {
  // ... subscribe logic ...
}

class ShippingSubscriber {
  constructor(
    private commandBus: CommandBus, // Injected command bus
    private eventBus: EventBus // Injected event bus
  ) {}

  async onOrderPlaced(event: { type: 'OrderPlaced'; orderId: string }) {
    console.log(
      `Shipping (choreography): Received OrderPlaced for ${event.orderId}.`
    );
    await this.commandBus.send('InitiatePackaging', { orderId: event.orderId });
    await this.eventBus.emit('ShipmentScheduled', { orderId: event.orderId });
  }
}

State‑Management Nuance

// Example: rebuilding state via event sourcing in a choreographed service
// EventLog interface for DI
interface EventLog {
  getEvents(type: string): any[];
}

class InventoryService {
  private reserved: Record<string, string> = {};
  constructor(private eventLog: EventLog) {} // Injected event log

  async rebuildState() {
    const events = this.eventLog.getEvents('StockReserved');
    for (const { orderId, sku } of events) {
      this.reserved[orderId] = sku;
    }
    console.log('State rebuilt from event log:', this.reserved);
  }
}

Choreographed services can manage their local state via event sourcing—replaying an event log to rebuild state on restart, enabling rich audit trails without central orchestration.

Visualising the Patterns

Below is a diagram illustrating the difference between orchestration (centralised control) and choreography (decentralised flow). Orchestration is like a conductor leading an orchestra—one central figure directs each part. Choreography is more like a jazz band, where each musician listens and responds, creating harmony without a single leader.

Choreography

Command: ReserveStock

Event: StockReserved

Command: ScheduleShipment

Event: ShipmentScheduled

Inventory Service

Billing Service

Shipping Service

Notification Service

Analytics Service

Orchestration

OrderPlaced Event

Orchestrator Determines Next Step

Emit ProcessPayment

PaymentConfirmed Event

Emit FulfilOrder

Separating messaging semantics (commands vs. events) from control-flow patterns (orchestration vs. choreography) gives you the flexibility to design robust, adaptable distributed systems. Choose the right tool for each job, and don't conflate the two dimensions.

Conclusion

By disentangling messaging semantics from control‑flow patterns, you gain architectural agility, robust orchestrators, and flexible choreographies. This separation allows you to adapt your system as requirements evolve, without being locked into a single pattern.

distributed-systems event-driven-architecture