Loading learning content...
The debate between choreography and orchestration often presents a false dichotomy: you must choose one or the other. In practice, the most successful distributed systems blend both patterns, leveraging orchestration's visibility where it matters most and choreography's decoupling where autonomy is essential.
Hybrid approaches recognize a fundamental truth: different parts of your system have different requirements. Complex order fulfillment might need orchestration's explicit control, while downstream analytics and notifications benefit from choreography's loose coupling. Cross-domain communication might be event-driven (choreography), while within-domain workflows use orchestration.
This page explores proven hybrid patterns, showing you how to combine both approaches effectively. You'll learn to design architectures that capture the benefits of each pattern while minimizing their drawbacks.
By the end of this page, you will understand hybrid coordination patterns: orchestration within domains with choreography across domains, event-carried state transfer, the orchestrator-as-publisher pattern, and saga coordination. You'll be able to design architectures that blend both paradigms thoughtfully.
Before diving into patterns, let's understand why pure choreography or pure orchestration often falls short in complex systems.
The Problem with Pure Choreography:
As systems grow, pure choreography creates challenges:
The Problem with Pure Orchestration:
Pure orchestration creates its own challenges:
Hybrid Approaches Solve Both:
By combining patterns, you can:
| Challenge | Pure Choreography | Pure Orchestration | Hybrid Approach |
|---|---|---|---|
| Complex workflow visibility | Poor — distributed | Excellent — centralized | Good — orchestrate core, choreograph periphery |
| Team autonomy | Excellent | Poor | Good — teams own domains, orchestrator owns flow |
| Adding new consumers | Excellent | Poor — requires orchestrator change | Good — emit events from orchestrator |
| Cross-domain decoupling | Excellent | Poor | Good — choreography across domains |
| Debugging complexity | High | Low | Medium — limited scope for each |
Hybrid approaches add complexity by requiring teams to understand both patterns. Use them when the benefits outweigh this cognitive overhead. For simple systems, stick with one pattern. For complex enterprise systems, hybrid approaches often provide the best balance.
The most common hybrid pattern: use orchestration within a bounded context (domain), but choreography to communicate across bounded contexts.
How It Works:
Example: E-commerce Platform
Cross-domain communication is via events:
OrderConfirmed → Payment Domain starts payment workflowPaymentCompleted → Fulfillment Domain starts fulfillment workflowShipmentDispatched → Order Domain updates order status123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// ORDER DOMAIN - Internal orchestrationclass OrderDomainOrchestrator { async processOrder(input: CreateOrderInput): Promise<Order> { // Internal orchestration: Order domain owns this flow const result = await this.orderWorkflow.execute({ steps: [ validateOrder, checkCustomerCredit, applyDiscounts, calculateTax, createOrder, // Final step: emit event for other domains emitOrderConfirmedEvent, ], input, }); return result.order; } // Event published at end of orchestrated flow private async emitOrderConfirmedEvent(data: OrderData): Promise<void> { await this.eventBus.publish('OrderConfirmed', { eventId: uuid(), orderId: data.order.id, customerId: data.order.customerId, items: data.order.items, totalAmount: data.order.total, shippingAddress: data.order.shippingAddress, timestamp: new Date().toISOString(), }); } // React to events from other domains (choreography) async handlePaymentCompleted(event: PaymentCompletedEvent): Promise<void> { // Update order state based on external event await this.orderService.markAsPaid(event.orderId); } async handleShipmentDispatched(event: ShipmentDispatchedEvent): Promise<void> { await this.orderService.markAsShipped(event.orderId, event.trackingNumber); }} // PAYMENT DOMAIN - Internal orchestrationclass PaymentDomainOrchestrator { // React to OrderConfirmed event (choreography) async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { // Start internal payment orchestration await this.paymentWorkflow.execute({ steps: [ retrievePaymentMethod, validatePaymentMethod, processCharge, handlePaymentResult, emitPaymentEvent, // Emit result to other domains ], input: { orderId: event.orderId, customerId: event.customerId, amount: event.totalAmount, }, }); } private async emitPaymentEvent(data: PaymentData): Promise<void> { if (data.result.success) { await this.eventBus.publish('PaymentCompleted', { eventId: uuid(), orderId: data.orderId, paymentId: data.result.paymentId, amount: data.amount, timestamp: new Date().toISOString(), }); } else { await this.eventBus.publish('PaymentFailed', { eventId: uuid(), orderId: data.orderId, reason: data.result.failureReason, retryable: data.result.retryable, timestamp: new Date().toISOString(), }); } }} // FULFILLMENT DOMAIN - Internal orchestrationclass FulfillmentDomainOrchestrator { // React to PaymentCompleted event (choreography) async handlePaymentCompleted(event: PaymentCompletedEvent): Promise<void> { // Start fulfillment orchestration await this.fulfillmentWorkflow.execute({ steps: [ selectWarehouse, allocateInventory, createPickList, schedulePacking, arrangeShipping, emitFulfillmentEvents, ], input: { orderId: event.orderId, paymentId: event.paymentId, }, }); }}This pattern aligns naturally with Domain-Driven Design. Each bounded context has its own orchestrator for internal complexity. Context boundaries become event boundaries. This prevents domain logic from leaking across boundaries while maintaining workflow clarity within each domain.
In this pattern, the orchestrator publishes events at key workflow milestones, enabling independent consumers without requiring orchestrator changes.
How It Works:
This gives you orchestration's visibility for the core flow while enabling choreography's extensibility for peripheral concerns.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// Order Orchestrator publishes events for extensionclass OrderOrchestrator { async executeOrderWorkflow(input: OrderInput): Promise<OrderResult> { const ctx = { orderId: uuid(), correlationId: uuid() }; // Step 1: Create Order const order = await this.orderService.create(input, ctx); await this.publishEvent('OrderCreated', { orderId: ctx.orderId, items: order.items, }); // Step 2: Process Payment (core workflow continues) const payment = await this.paymentService.process({ orderId: ctx.orderId, amount: order.total, }, ctx); await this.publishEvent('OrderPaymentCompleted', { orderId: ctx.orderId, paymentId: payment.id, }); // Step 3: Reserve Inventory const inventory = await this.inventoryService.reserve({ orderId: ctx.orderId, items: order.items, }, ctx); await this.publishEvent('OrderInventoryReserved', { orderId: ctx.orderId, reservationId: inventory.id, }); // Step 4: Schedule Shipping const shipment = await this.shippingService.schedule({ orderId: ctx.orderId, reservationId: inventory.id, address: input.shippingAddress, }, ctx); await this.publishEvent('OrderShipmentScheduled', { orderId: ctx.orderId, shipmentId: shipment.id, trackingNumber: shipment.trackingNumber, }); // Step 5: Complete await this.orderService.complete(ctx.orderId); await this.publishEvent('OrderCompleted', { orderId: ctx.orderId, completedAt: new Date(), }); return { orderId: ctx.orderId, status: 'completed' }; }} // INDEPENDENT CONSUMERS - Don't need orchestrator changes // Analytics Service - tracks conversion funnelsclass AnalyticsConsumer { @Subscribe('OrderCreated') async onOrderCreated(event: OrderCreatedEvent) { await this.analytics.trackFunnelStep('order_created', event.orderId); } @Subscribe('OrderCompleted') async onOrderCompleted(event: OrderCompletedEvent) { await this.analytics.trackConversion(event.orderId, event.completedAt); }} // Marketing Service - triggers campaignsclass MarketingConsumer { @Subscribe('OrderCompleted') async onOrderCompleted(event: OrderCompletedEvent) { const order = await this.orderClient.getOrder(event.orderId); if (order.isFirstOrder) { await this.campaigns.sendWelcomeSequence(order.customerId); } await this.campaigns.requestReview(order.customerId, order.orderId); }} // Loyalty Service - awards pointsclass LoyaltyConsumer { @Subscribe('OrderPaymentCompleted') async onPaymentCompleted(event: OrderPaymentCompletedEvent) { const order = await this.orderClient.getOrder(event.orderId); const points = this.calculatePoints(order.total); await this.loyaltyService.awardPoints(order.customerId, points); }} // Fraud Detection - real-time monitoringclass FraudMonitoringConsumer { @Subscribe('OrderCreated') async onOrderCreated(event: OrderCreatedEvent) { const order = await this.orderClient.getOrder(event.orderId); await this.fraudDetection.analyzeOrder(order); } @Subscribe('OrderPaymentCompleted') async onPaymentCompleted(event: OrderPaymentCompletedEvent) { await this.fraudDetection.recordPaymentSuccess(event.orderId); }}Key Insight: The orchestrator defines and controls the core business workflow. But it also acts as an event source, enabling an unlimited number of peripheral services to react without modifying the orchestrator.
When new requirements arise:
OrderShipmentScheduled.OrderInventoryReserved.None of these require orchestrator changes. The core workflow remains stable and visible; extensions happen through event subscription.
When using this pattern, design events with unknown consumers in mind. Include enough context that typical consumers don't need to call back to the orchestrator. Consider what analytics, marketing, or compliance teams might need from each event.
In complex distributed transactions, a Saga Coordinator provides visibility and control over a choreographed saga without fully centralizing it.
How It Works:
This gives you choreography's loose coupling with orchestration's observability.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
// Saga Coordinator: Observes but doesn't control interface SagaDefinition { name: string; expectedEvents: EventExpectation[]; timeout: Duration;} interface EventExpectation { eventType: string; afterEvent?: string; timeout: Duration; optional: boolean;} const orderSagaDefinition: SagaDefinition = { name: 'OrderProcessing', expectedEvents: [ { eventType: 'OrderCreated', timeout: '1m', optional: false }, { eventType: 'InventoryReserved', afterEvent: 'OrderCreated', timeout: '5m', optional: false }, { eventType: 'PaymentCompleted', afterEvent: 'InventoryReserved', timeout: '5m', optional: false }, { eventType: 'ShipmentScheduled', afterEvent: 'PaymentCompleted', timeout: '30m', optional: false }, { eventType: 'OrderCompleted', afterEvent: 'ShipmentScheduled', timeout: '1m', optional: false }, ], timeout: '1h',}; class SagaCoordinator { constructor( private readonly sagaStore: SagaStateStore, private readonly alerting: AlertingService, ) {} // Called when any saga-related event is observed async observeEvent(event: DomainEvent): Promise<void> { const saga = await this.findOrCreateSaga(event); // Record event observation await this.sagaStore.recordEvent(saga.id, { eventType: event.eventType, eventId: event.eventId, observedAt: new Date(), }); // Check if saga is complete if (this.isComplete(saga, event)) { await this.completeSaga(saga.id); return; } // Update expected next events and timeouts await this.updateExpectations(saga, event); } // Background job: Check for stuck sagas async checkTimeouts(): Promise<void> { const stuckSagas = await this.sagaStore.findSagasWithExpiredTimeouts(); for (const saga of stuckSagas) { const missingEvent = this.findMissingEvent(saga); // Alert operations team await this.alerting.send({ severity: 'high', title: 'Saga Timeout', message: `Order ${saga.correlationId} stuck waiting for ${missingEvent}`, saga: saga, suggestedActions: this.getSuggestedActions(saga, missingEvent), }); // Optionally: Trigger compensation if (saga.autoCompensateOnTimeout) { await this.triggerCompensation(saga); } } } // Compensation: Emit compensating command events async triggerCompensation(saga: SagaState): Promise<void> { // The coordinator emits compensation REQUEST events // Services still decide how to compensate const completedSteps = saga.observedEvents.map(e => e.eventType); // Emit compensation requests in reverse order if (completedSteps.includes('ShipmentScheduled')) { await this.eventBus.publish('CompensationRequested', { type: 'CancelShipment', sagaId: saga.id, correlationId: saga.correlationId, reason: saga.compensationReason, }); } if (completedSteps.includes('PaymentCompleted')) { await this.eventBus.publish('CompensationRequested', { type: 'RefundPayment', sagaId: saga.id, correlationId: saga.correlationId, reason: saga.compensationReason, }); } // ... continue for other steps await this.sagaStore.markCompensating(saga.id); } // Dashboard API: Get saga state for debugging async getSagaStatus(correlationId: string): Promise<SagaStatusView> { const saga = await this.sagaStore.findByCorrelationId(correlationId); return { id: saga.id, correlationId: saga.correlationId, status: saga.status, observedEvents: saga.observedEvents, pendingEvent: this.getNextExpectedEvent(saga), timeUntilTimeout: this.getTimeUntilTimeout(saga), canCompensate: this.canCompensate(saga), compensationActions: this.getAvailableCompensations(saga), }; }} // Services remain loosely coupled - react to eventsclass ShippingService { @Subscribe('PaymentCompleted') async onPaymentCompleted(event: PaymentCompletedEvent) { // Still choreography: service reacts to event const shipment = await this.scheduleShipment(event.orderId); // Emit completion event await this.eventBus.publish('ShipmentScheduled', { orderId: event.orderId, shipmentId: shipment.id, trackingNumber: shipment.trackingNumber, }); } @Subscribe('CompensationRequested') async onCompensationRequested(event: CompensationRequestedEvent) { if (event.type !== 'CancelShipment') return; await this.cancelShipment(event.correlationId); await this.eventBus.publish('ShipmentCancelled', { correlationId: event.correlationId, sagaId: event.sagaId, }); }}Event-Carried State Transfer is a hybrid pattern where events carry enough data that consumers don't need to call back to the source. It enables choreography's loose coupling while avoiding the callback round-trips that often re-introduce coupling.
The Problem:
In naive choreography, events often carry minimal data:
{ eventType: 'OrderCreated', orderId: '123' }
Consumers must then call the Order Service to get order details, creating runtime coupling:
The Solution:
Include enough state in the event that consumers are self-sufficient:
{ eventType: 'OrderCreated', orderId: '123', items: [...], customer: {...}, total: 150 }
Now consumers have what they need without callbacks.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// MINIMAL EVENT (Requires Callbacks)interface OrderCreatedEventMinimal { eventType: 'OrderCreated'; orderId: string; timestamp: string;} // Consumer must fetch dataclass ShippingServiceWithCallbacks { async onOrderCreated(event: OrderCreatedEventMinimal) { // Must call back to Order Service const order = await this.orderClient.getOrder(event.orderId); // Now we can work... await this.prepareShipping(order); }} // ENRICHED EVENT (Self-Sufficient Consumers)interface OrderCreatedEventEnriched { eventType: 'OrderCreated'; eventId: string; timestamp: string; correlationId: string; // Order data orderId: string; orderNumber: string; createdAt: string; // Items - enough for inventory, shipping, analytics items: Array<{ productId: string; sku: string; name: string; quantity: number; unitPrice: number; weight: number; dimensions: Dimensions; }>; // Customer info - enough for fraud, marketing, notifications customer: { id: string; email: string; name: string; tier: 'standard' | 'premium' | 'vip'; isFirstOrder: boolean; totalLifetimeValue: number; }; // Shipping - enough for fulfillment shippingAddress: { street: string; city: string; state: string; country: string; postalCode: string; }; // Totals - enough for payment, analytics subtotal: number; tax: number; shipping: number; total: number; currency: string; // Business context - enough for various decisions channel: 'web' | 'mobile' | 'api'; promotionCodes: string[]; giftMessage?: string;} // Consumer is self-sufficientclass ShippingServiceSelfSufficient { async onOrderCreated(event: OrderCreatedEventEnriched) { // No callbacks needed - everything is in the event const shippingDetails = { destination: event.shippingAddress, items: event.items.map(item => ({ sku: item.sku, quantity: item.quantity, weight: item.weight, dimensions: item.dimensions, })), priority: event.customer.tier === 'vip' ? 'express' : 'standard', }; await this.prepareShipping(shippingDetails); }} // Multiple consumers can work independentlyclass AnalyticsServiceSelfSufficient { async onOrderCreated(event: OrderCreatedEventEnriched) { await this.track({ event: 'order_created', orderId: event.orderId, customerId: event.customer.id, orderValue: event.total, itemCount: event.items.length, channel: event.channel, isFirstOrder: event.customer.isFirstOrder, customerTier: event.customer.tier, }); }} class FraudServiceSelfSufficient { async onOrderCreated(event: OrderCreatedEventEnriched) { const riskScore = await this.assessRisk({ orderValue: event.total, customerLifetimeValue: event.customer.totalLifetimeValue, isNewCustomer: event.customer.isFirstOrder, shippingCountry: event.shippingAddress.country, itemCategories: event.items.map(i => this.getCategory(i.productId)), }); if (riskScore > 0.8) { await this.flagForReview(event.orderId, riskScore); } }}Trade-offs of Event-Carried State Transfer:
Pros:
Cons:
When to Use:
Event-carried state transfer means many consumers see the event data. Never include raw payment credentials, SSNs, or other sensitive PII that shouldn't propagate. Mask or tokenize sensitive fields. Consider what happens if the event is logged, stored in dead letter queues, or accessed by future consumers you haven't anticipated.
In complex systems, coordination needs vary by layer. Layered Coordination applies different patterns at different architectural levels.
Common Layering:
Layer 1: API Gateway / Edge
Layer 2: Domain Orchestrators
Layer 3: Cross-Domain Events
Layer 4: Integration / External Systems
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// LAYER 1: API Gatewayclass OrderApiController { @Post('/orders') async createOrder(@Body() req: CreateOrderRequest): Promise<OrderResponse> { // Gateway invokes the appropriate domain orchestrator const result = await this.orderOrchestrator.createOrder({ ...req, correlationId: req.headers['x-correlation-id'] || uuid(), }); return { orderId: result.orderId, status: result.status }; }} // LAYER 2: Domain Orchestratorclass OrderDomainOrchestrator { async createOrder(input: CreateOrderInput): Promise<OrderCreationResult> { // Orchestrate within the Order domain return this.workflow.execute({ input, steps: [ // Internal orchestration validateOrderRequest, enrichCustomerData, calculatePricing, applyPromotions, persistOrder, // Emit event for Layer 3 (cross-domain choreography) publishOrderCreatedEvent, ], }); }} // LAYER 3: Cross-Domain Event Handlersclass PaymentDomainEventHandler { @Subscribe('OrderCreated') async onOrderCreated(event: OrderCreatedEvent): Promise<void> { // Choreography: react to event from another domain // Then orchestrate within Payment domain await this.paymentOrchestrator.processPayment({ orderId: event.orderId, amount: event.total, customerId: event.customer.id, }); }} // LAYER 4: External Integration Orchestratorclass PaymentGatewayOrchestrator { async processPayment(input: PaymentInput): Promise<PaymentResult> { // Orchestrate external payment processing return this.workflow.execute({ input, steps: [ selectPaymentGateway, // Choose Stripe, Braintree, etc. preparePaymentRequest, executePaymentWithRetry, // Retry logic for external API handlePaymentResponse, persistPaymentRecord, publishPaymentResult, ], // External API-specific configuration retryPolicy: { maxAttempts: 3, backoff: 'exponential', circuitBreaker: { threshold: 5, resetAfter: '30s', }, }, timeout: '30s', }); }} // ARCHITECTURE OVERVIEWconst layeredCoordinationArchitecture = { layer1_edge: { pattern: 'Request-Response', components: ['API Gateway', 'Load Balancer', 'Auth'], responsibility: 'Route requests to domain orchestrators', }, layer2_domainOrchestration: { pattern: 'Orchestration', components: ['Order Orchestrator', 'Payment Orchestrator', 'Fulfillment Orchestrator'], responsibility: 'Control complex workflows within bounded contexts', }, layer3_crossDomainEvents: { pattern: 'Choreography', components: ['Event Bus', 'Schema Registry', 'Event Store'], responsibility: 'Loose coupling between domains via events', }, layer4_externalIntegration: { pattern: 'Orchestration', components: ['Integration Orchestrators', 'Circuit Breakers', 'Retry Handlers'], responsibility: 'Reliable communication with external systems', },};Each layer has different failure characteristics. External APIs fail differently than internal services. Cross-domain events have different reliability than internal orchestration. Choose patterns that handle each layer's specific failure modes appropriately.
Implementing hybrid patterns requires careful attention to infrastructure, team practices, and operational considerations.
Infrastructure Requirements:
For Choreography Layers:
For Orchestration Layers:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// Hybrid Architecture Configuration interface HybridArchitectureConfig { orchestration: { engine: 'temporal' | 'step-functions' | 'custom'; stateStore: DatabaseConfig; workerConfig: WorkerPoolConfig; }; choreography: { eventBus: 'kafka' | 'rabbitmq' | 'sns-sqs'; schemaRegistry: 'confluent' | 'apicurio' | 'custom'; deadLetterConfig: DLQConfig; }; observability: { tracing: 'jaeger' | 'zipkin' | 'xray'; logging: 'elasticsearch' | 'cloudwatch' | 'datadog'; metrics: 'prometheus' | 'cloudwatch' | 'datadog'; };} const productionConfig: HybridArchitectureConfig = { orchestration: { engine: 'temporal', stateStore: { type: 'postgresql', host: 'temporal-db.internal', replication: true, }, workerConfig: { minWorkers: 5, maxWorkers: 50, scaleMetric: 'pending_workflows', }, }, choreography: { eventBus: 'kafka', schemaRegistry: 'confluent', deadLetterConfig: { topic: 'dlq.events', retentionDays: 30, alertThreshold: 100, }, }, observability: { tracing: 'jaeger', logging: 'elasticsearch', metrics: 'prometheus', },}; // Team Practices for Hybrid Architecture const teamPractices = { eventDesign: { // All events must be registered in schema registry schemaRegistration: 'required', // Events must include correlation ID for tracing correlationIdRequired: true, // Events must be versioned versioningStrategy: 'semantic', // Breaking changes require compatibility check compatibilityMode: 'backward', }, orchestratorOwnership: { // Each domain owns its orchestrator ownershipModel: 'domain-aligned', // Orchestrator changes go through domain team changeApproval: 'domain-team', // Cross-domain impacts require architecture review crossDomainChanges: 'architecture-review', }, eventOwnership: { // Producer owns the event schema schemaOwnership: 'producer', // Consumers test against producer contracts contractTesting: 'consumer-driven', // Schema changes require consumer notification changeNotification: 'schema-registry-webhook', }, debuggingPractices: { // Correlation ID must flow through all layers correlationIdPropagation: 'mandatory', // All events logged to centralized system eventLogging: 'centralized', // Saga view service aggregates state sagaTracking: 'saga-coordinator', },};You don't need to implement the full hybrid architecture immediately. Start with one pattern, add the other where needed. Many systems evolve: start with orchestration for visibility, add event publication for extension, discover domains that want autonomy and move them to internal choreography.
We've explored hybrid approaches that combine choreography and orchestration. Let's consolidate the key insights:
Module Complete:
You've completed the in-depth exploration of Choreography vs Orchestration. You now understand:
These patterns form the foundation for coordinating work in distributed systems. Whether you're building microservices, event-driven architectures, or complex workflows, you now have the knowledge to choose and implement the right coordination approach for your specific needs.
Congratulations! You've mastered the coordination patterns for event-driven architectures. You understand choreography, orchestration, sagas, and hybrid approaches—and most importantly, you know when to use each. Apply these patterns thoughtfully in your systems, always evaluating trade-offs in your specific context.