Skip to content

Microservices Orchestration

Verified by tests

CreateShipmentReceptorTests, PostAllPerspectivesTests — library CI run #31657041675 (2026-08-13)

Implement saga orchestration patterns with Whizbang for distributed workflows, compensation handling, and complex multi-service coordination.

Updated

Whizbang ships a dedicated Whizbang.Sagas package (saga state repositories, completion watchdogs, abandonment events — see SagaServiceCollectionExtensions). The hand-rolled orchestrator below teaches the underlying pattern using core primitives: event receptors (IReceptor<TEvent, TCommand>) that advance saga state and dispatch the next command via IDispatcher.SendAsync. The ECommerce sample's PaymentShippingReceptor is a minimal single-step version of exactly this pattern.


Orchestration vs. Choreography

flowchart TD
    subgraph CHOREO["Choreography (Decentralized)"]
        OrderService["OrderService"] --> OC["OrderCreated"] --> InventoryService["InventoryService"] --> IR["InventoryReserved"] --> PaymentService["PaymentService"] --> PP["PaymentProcessed"]
        ChoreoNotes["❌ No central coordinator<br/>❌ Hard to track overall state<br/>✅ Loose coupling"]
    end

    class OrderService,InventoryService,PaymentService layer-core
    class OC,IR,PP layer-event
flowchart TD
    subgraph ORCH["Orchestration (Centralized)"]
        OrderSaga["OrderSaga<br/>(Process Manager)"]
        OrderSaga --> InvSvc["Inventory Service"]
        OrderSaga --> PaySvc["Payment Service"]
        OrderSaga --> ShipSvc["Shipping Service"]
        OrchNotes["✅ Central coordinator<br/>✅ Easy to track state<br/>✅ Complex workflows<br/>❌ Tighter coupling"]
    end

    class OrderSaga layer-command
    class InvSvc,PaySvc,ShipSvc layer-core

Saga State Machine

OrderSagaState.cs:

Saga State Machine

public record OrderSagaState {
  public required string SagaId { get; init; }
  public required string OrderId { get; init; }
  public required SagaStatus Status { get; init; }
  public required SagaStep CurrentStep { get; init; }
  public string? PaymentId { get; init; }
  public string? ShipmentId { get; init; }
  public string? ErrorMessage { get; init; }
  public DateTime CreatedAt { get; init; }
  public DateTime UpdatedAt { get; init; }
  public Dictionary<string, string> Metadata { get; init; } = new();
}

public enum SagaStatus {
  Started,
  InProgress,
  Completed,
  Compensating,
  Compensated,
  Failed
}

public enum SagaStep {
  OrderCreated,
  InventoryReserving,
  InventoryReserved,
  PaymentProcessing,
  PaymentProcessed,
  ShipmentCreating,
  ShipmentCreated,
  OrderCompleted
}

Saga Orchestrator (Event Receptors)

The orchestrator is a set of event receptors — each saga step reacts to an event, persists saga state, and dispatches the next command with IDispatcher.SendAsync. This is the same shape as the ECommerce sample's PaymentShippingReceptor (IReceptor<PaymentProcessedEvent, CreateShipmentCommand>), extended with durable saga state.

OrderSagaReceptors.cs:

Saga Orchestrator

using Whizbang.Core;
using Whizbang.Core.ValueObjects;

// Start saga: OrderCreated → ReserveInventory
public class OrderCreatedSagaReceptor(
  SagaStateStore store,
  IDispatcher dispatcher,
  ILogger<OrderCreatedSagaReceptor> logger
) : IReceptor<OrderCreated, ReserveInventory> {

  public async ValueTask<ReserveInventory> HandleAsync(
    OrderCreated message,
    CancellationToken cancellationToken = default
  ) {
    var state = new OrderSagaState {
      SagaId = TrackedGuid.NewMedo().Value.ToString("N"),
      OrderId = message.OrderId,
      Status = SagaStatus.InProgress,
      CurrentStep = SagaStep.InventoryReserving,
      CreatedAt = DateTime.UtcNow,
      UpdatedAt = DateTime.UtcNow
    };
    await store.SaveAsync(state, cancellationToken);

    var command = new ReserveInventory(OrderId: message.OrderId, Items: message.Items);
    await dispatcher.SendAsync(command);

    logger.LogInformation("Order saga {SagaId} started for order {OrderId}", state.SagaId, message.OrderId);
    return command;
  }
}

// Continue saga: InventoryReserved → ProcessPayment
public class InventoryReservedSagaReceptor(
  SagaStateStore store,
  IDispatcher dispatcher,
  ILogger<InventoryReservedSagaReceptor> logger
) : IReceptor<InventoryReserved, ProcessPayment> {

  public async ValueTask<ProcessPayment> HandleAsync(
    InventoryReserved message,
    CancellationToken cancellationToken = default
  ) {
    var state = await store.LoadByOrderIdAsync(message.OrderId, cancellationToken)
      ?? throw new InvalidOperationException($"Saga not found for order {message.OrderId}");

    state = state with { CurrentStep = SagaStep.PaymentProcessing, UpdatedAt = DateTime.UtcNow };
    await store.SaveAsync(state, cancellationToken);

    var command = new ProcessPayment(OrderId: message.OrderId, Amount: message.TotalAmount);
    await dispatcher.SendAsync(command);

    logger.LogInformation("Saga {SagaId}: Inventory reserved, processing payment", state.SagaId);
    return command;
  }
}

// Compensate: PaymentFailed → ReleaseInventory
public class PaymentFailedSagaReceptor(
  SagaStateStore store,
  IDispatcher dispatcher,
  ILogger<PaymentFailedSagaReceptor> logger
) : IReceptor<PaymentFailed, ReleaseInventory> {

  public async ValueTask<ReleaseInventory> HandleAsync(
    PaymentFailed message,
    CancellationToken cancellationToken = default
  ) {
    var state = await store.LoadByOrderIdAsync(message.OrderId, cancellationToken)
      ?? throw new InvalidOperationException($"Saga not found for order {message.OrderId}");

    state = state with {
      Status = SagaStatus.Compensating,
      CurrentStep = SagaStep.InventoryReserved,
      ErrorMessage = message.Reason,
      UpdatedAt = DateTime.UtcNow
    };
    await store.SaveAsync(state, cancellationToken);

    var command = new ReleaseInventory(OrderId: message.OrderId);
    await dispatcher.SendAsync(command);

    logger.LogWarning("Saga {SagaId}: Payment failed, releasing inventory", state.SagaId);
    return command;
  }
}

// Remaining steps follow the same pattern:
// - InventoryInsufficientSagaReceptor : IReceptor<InventoryInsufficient, CancelOrder>
// - PaymentProcessedSagaReceptor     : IReceptor<PaymentProcessed, CreateShipment>
// - ShipmentCreatedSagaReceptor      : IReceptor<ShipmentCreated>  (void receptor — marks saga Completed)

SagaStateStore.cs (durable saga state via Dapper):

Saga State Store

public class SagaStateStore(NpgsqlDataSource dataSource) {

  public async Task SaveAsync(OrderSagaState state, CancellationToken ct) {
    await using var db = await dataSource.OpenConnectionAsync(ct);
    await db.ExecuteAsync(
      """
      INSERT INTO saga_state (
        saga_id, order_id, status, current_step, payment_id, shipment_id, error_message, created_at, updated_at, metadata
      )
      VALUES (@SagaId, @OrderId, @Status, @CurrentStep, @PaymentId, @ShipmentId, @ErrorMessage, @CreatedAt, @UpdatedAt, @Metadata::jsonb)
      ON CONFLICT (saga_id) DO UPDATE SET
        status = @Status,
        current_step = @CurrentStep,
        payment_id = @PaymentId,
        shipment_id = @ShipmentId,
        error_message = @ErrorMessage,
        updated_at = @UpdatedAt
      """,
      new {
        state.SagaId,
        state.OrderId,
        Status = state.Status.ToString(),
        CurrentStep = state.CurrentStep.ToString(),
        state.PaymentId,
        state.ShipmentId,
        state.ErrorMessage,
        state.CreatedAt,
        state.UpdatedAt,
        Metadata = JsonSerializer.Serialize(state.Metadata)
      }
    );
  }

  public async Task<OrderSagaState?> LoadByOrderIdAsync(string orderId, CancellationToken ct) {
    await using var db = await dataSource.OpenConnectionAsync(ct);
    return await db.QuerySingleOrDefaultAsync<OrderSagaState>(
      """
      SELECT saga_id, order_id, status, current_step, payment_id, shipment_id, error_message, created_at, updated_at
      FROM saga_state
      WHERE order_id = @OrderId
      """,
      new { OrderId = orderId }
    );
  }
}

Updated

Perspectives are pure Apply functions and cannot dispatch commands — orchestration logic that sends the next command belongs in receptors (events can have receptors). Use PublishOnceAsync(claimKey, eventData) on IDispatcher when concurrent handlers might emit the same saga event twice — the conventional claim key is the saga id.


Saga Database Schema

Migrations/001_CreateSagaTables.sql:

Saga Database Schema

CREATE TABLE saga_state (
  saga_id TEXT PRIMARY KEY,
  order_id TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL,
  current_step TEXT NOT NULL,
  payment_id TEXT,
  shipment_id TEXT,
  error_message TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX idx_saga_state_order_id ON saga_state(order_id);
CREATE INDEX idx_saga_state_status ON saga_state(status);
CREATE INDEX idx_saga_state_created_at ON saga_state(created_at DESC);

Timeout Handling

Saga timeouts for hung processes:

SagaTimeoutMonitor.cs:

Timeout Handling

public class SagaTimeoutMonitor : BackgroundService {
  private readonly NpgsqlDataSource _dataSource;
  private readonly IDispatcher _dispatcher;
  private readonly ILogger<SagaTimeoutMonitor> _logger;

  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    while (!stoppingToken.IsCancellationRequested) {
      try {
        await using var db = await _dataSource.OpenConnectionAsync(stoppingToken);

        // Find sagas stuck in progress for > 10 minutes
        var stuckSagas = await db.QueryAsync<OrderSagaState>(
          """
          SELECT saga_id, order_id, status, current_step, updated_at
          FROM saga_state
          WHERE status = 'InProgress'
            AND updated_at < NOW() - INTERVAL '10 minutes'
          """
        );

        foreach (var saga in stuckSagas) {
          _logger.LogWarning(
            "Saga {SagaId} timed out at step {CurrentStep}, compensating",
            saga.SagaId,
            saga.CurrentStep
          );

          // Trigger compensation
          await CompensateSagaAsync(saga, stoppingToken);
        }

        await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
      } catch (Exception ex) when (ex is not OperationCanceledException) {
        _logger.LogError(ex, "Error in saga timeout monitor");
        await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
      }
    }
  }

  private async Task CompensateSagaAsync(
    OrderSagaState saga,
    CancellationToken ct
  ) {
    // Send compensation commands based on current step
    switch (saga.CurrentStep) {
      case SagaStep.PaymentProcessing:
        // Release inventory
        await _dispatcher.SendAsync(new ReleaseInventory(saga.OrderId));
        break;

      case SagaStep.ShipmentCreating:
        // Refund payment and release inventory
        await _dispatcher.SendAsync(new RefundPayment(saga.OrderId, saga.PaymentId!));
        await _dispatcher.SendAsync(new ReleaseInventory(saga.OrderId));
        break;
    }

    // Update saga to compensating
    await using var db = await _dataSource.OpenConnectionAsync(ct);
    await db.ExecuteAsync(
      """
      UPDATE saga_state
      SET status = 'Compensating', error_message = 'Timeout', updated_at = NOW()
      WHERE saga_id = @SagaId
      """,
      new { SagaId = saga.SagaId }
    );
  }
}

Saga Visualization API

SagasController.cs:

Saga Visualization API

[ApiController]
[Route("api/[controller]")]
public class SagasController : ControllerBase {
  private readonly NpgsqlConnection _db;

  [HttpGet("{sagaId}")]
  public async Task<IActionResult> GetSaga(string sagaId) {
    var saga = await _db.QuerySingleOrDefaultAsync<OrderSagaState>(
      "SELECT * FROM saga_state WHERE saga_id = @SagaId",
      new { SagaId = sagaId }
    );

    if (saga == null) {
      return NotFound();
    }

    return Ok(new {
      saga.SagaId,
      saga.OrderId,
      saga.Status,
      saga.CurrentStep,
      saga.PaymentId,
      saga.ShipmentId,
      saga.ErrorMessage,
      saga.CreatedAt,
      saga.UpdatedAt,
      Steps = GetSagaSteps(saga)
    });
  }

  private object[] GetSagaSteps(OrderSagaState saga) {
    var steps = new[] {
      new { Step = "OrderCreated", Status = "Completed", Timestamp = saga.CreatedAt },
      new { Step = "InventoryReserving", Status = GetStepStatus(saga, SagaStep.InventoryReserving), Timestamp = (DateTime?)null },
      new { Step = "InventoryReserved", Status = GetStepStatus(saga, SagaStep.InventoryReserved), Timestamp = (DateTime?)null },
      new { Step = "PaymentProcessing", Status = GetStepStatus(saga, SagaStep.PaymentProcessing), Timestamp = (DateTime?)null },
      new { Step = "PaymentProcessed", Status = GetStepStatus(saga, SagaStep.PaymentProcessed), Timestamp = (DateTime?)null },
      new { Step = "ShipmentCreating", Status = GetStepStatus(saga, SagaStep.ShipmentCreating), Timestamp = (DateTime?)null },
      new { Step = "ShipmentCreated", Status = GetStepStatus(saga, SagaStep.ShipmentCreated), Timestamp = (DateTime?)null }
    };

    return steps;
  }

  private string GetStepStatus(OrderSagaState saga, SagaStep step) {
    if (saga.CurrentStep == step) return "InProgress";
    if ((int)saga.CurrentStep > (int)step) return "Completed";
    return "Pending";
  }
}

Response:

Saga Visualization API (2)

{
  "sagaId": "abc123",
  "orderId": "order-456",
  "status": "InProgress",
  "currentStep": "PaymentProcessing",
  "steps": [
    { "step": "OrderCreated", "status": "Completed", "timestamp": "2024-12-12T10:00:00Z" },
    { "step": "InventoryReserving", "status": "Completed", "timestamp": "2024-12-12T10:01:00Z" },
    { "step": "InventoryReserved", "status": "Completed", "timestamp": "2024-12-12T10:02:00Z" },
    { "step": "PaymentProcessing", "status": "InProgress", "timestamp": null },
    { "step": "PaymentProcessed", "status": "Pending", "timestamp": null },
    { "step": "ShipmentCreating", "status": "Pending", "timestamp": null },
    { "step": "ShipmentCreated", "status": "Pending", "timestamp": null }
  ]
}

Key Takeaways

Centralized Coordination - Saga orchestrator manages workflow ✅ State Tracking - Saga state persisted at each step ✅ Compensation - Automatic rollback on failures ✅ Timeout Handling - Monitor and compensate hung sagas ✅ Visualization - API for tracking saga progress


When to Use Orchestration

Scenario Orchestration Choreography
Simple workflows ❌ Overkill ✅ Recommended
Complex workflows ✅ Recommended ❌ Hard to track
Long-running processes ✅ Recommended ❌ No visibility
High failure rates ✅ Better control ❌ Hard to compensate
Loose coupling ❌ Tighter coupling ✅ Decoupled

Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12