Understanding Commands & Events with Orchestration & Choreography
18 Jun 2025Designing 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.
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.
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.