Skip to main content

Sagas & Workflows

Sagas coordinate multi-step business processes where failure in one step requires compensating (rolling back) previously completed steps. Excalibur provides two saga APIs for different scenarios.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required packages:
    dotnet add package Excalibur.Saga
    dotnet add package Excalibur.Saga.SqlServer # for persistence
  • Familiarity with handlers and dependency injection

Two Approaches

Excalibur supports two saga patterns. Choose based on how your services communicate:

Step-BasedEvent-Driven
APIISagaDefinition + ISagaStepSagaBase<T> + ISagaCoordinator
ExecutionRuns all steps sequentially in one processProcesses one event at a time, suspends between events
Service callsSteps await service calls directlySteps publish commands/events via Dispatch
Best forIn-process coordination, API gateway, modular monolithCross-service microservices, long-running workflows
GuideBuilding Your First SagaOrchestration vs Choreography

Step-based sagas are simpler to write and debug. The orchestrator calls each service directly and handles compensation automatically. Use them when all participating services are reachable from a single process (e.g., a modular monolith or an API that calls internal services).

Event-driven sagas work across independently deployed microservices. Each step publishes a command, then the saga suspends until it receives a response event. State is persisted between events so the saga survives process restarts. Use them when services are truly independent and communicate only through messages.

New to sagas?

Start with Building Your First Saga to learn compensation, failure handling, and retry logic using the simpler step-based API. Then read Orchestration vs Choreography for the event-driven pattern used in microservice architectures.

Packages

PackagePurpose
Excalibur.SagaCore saga engine, orchestrator, coordinator
Excalibur.Saga.SqlServerSQL Server saga state persistence
Excalibur.Saga.PostgresPostgreSQL saga state persistence
Excalibur.Saga.CosmosDbAzure Cosmos DB saga state persistence
Excalibur.Saga.DynamoDbAWS DynamoDB saga state persistence
Excalibur.Saga.MongoDBMongoDB saga state persistence
Excalibur.Saga.FirestoreGoogle Firestore saga state persistence

Quick Start

1. Define a Saga

Create a saga by implementing ISagaDefinition<TSagaData>:

using Excalibur.Saga.Abstractions;

public class OrderSagaData
{
public Guid OrderId { get; set; }
public Guid PaymentId { get; set; }
public Guid ShipmentId { get; set; }
public bool InventoryReserved { get; set; }
}

public class OrderSaga : ISagaDefinition<OrderSagaData>
{
public string Name => "OrderSaga";
public TimeSpan Timeout => TimeSpan.FromMinutes(30);

public IReadOnlyList<ISagaStep<OrderSagaData>> Steps => new ISagaStep<OrderSagaData>[]
{
new ReserveInventoryStep(),
new ProcessPaymentStep(),
new ShipOrderStep()
};

// RetryPolicy is optional - return null for no retries
// To implement custom retry logic, implement ISagaRetryPolicy
public ISagaRetryPolicy? RetryPolicy => null;

public Task OnCompletedAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Called when all steps succeed
return Task.CompletedTask;
}

public Task OnFailedAsync(
ISagaContext<OrderSagaData> context,
Exception exception,
CancellationToken cancellationToken)
{
// Called when saga fails after compensation
return Task.CompletedTask;
}
}

2. Define Steps

Each step implements ISagaStep<TSagaData> with execute and compensate logic:

public class ReserveInventoryStep : ISagaStep<OrderSagaData>
{
public string Name => "ReserveInventory";
public bool CanCompensate => true;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);

public async Task<StepResult> ExecuteAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Reserve inventory for the order
context.Data.InventoryReserved = true;
return StepResult.Success();
}

public async Task<StepResult> CompensateAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Release the reserved inventory
context.Data.InventoryReserved = false;
return StepResult.Success();
}
}

public class ProcessPaymentStep : ISagaStep<OrderSagaData>
{
public string Name => "ProcessPayment";
public bool CanCompensate => true;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);

public async Task<StepResult> ExecuteAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Charge the customer
context.Data.PaymentId = Guid.NewGuid();
return StepResult.Success();
}

public async Task<StepResult> CompensateAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Refund the charge
context.Data.PaymentId = Guid.Empty;
return StepResult.Success();
}
}

public class ShipOrderStep : ISagaStep<OrderSagaData>
{
public string Name => "ShipOrder";
public bool CanCompensate => false; // Cannot un-ship
public TimeSpan Timeout => TimeSpan.FromMinutes(5);

public async Task<StepResult> ExecuteAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
context.Data.ShipmentId = Guid.NewGuid();
return StepResult.Success();
}

public Task<StepResult> CompensateAsync(
ISagaContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Not compensable - CanCompensate is false
return Task.FromResult(StepResult.Success());
}
}

3. Register and Execute

using Microsoft.Extensions.DependencyInjection;

// Registration with SQL Server persistence
services.AddExcaliburSaga(saga =>
{
saga.UseSqlServer(sql => { sql.ConnectionString = connectionString; })
.WithOrchestration()
.WithTimeouts();
});

// Or with Postgres persistence
services.AddExcaliburSaga(saga =>
{
saga.UsePostgres(opts => opts.ConnectionString = connectionString)
.WithOrchestration()
.WithTimeouts();
});

// Cloud providers
services.AddExcaliburSaga(saga =>
{
saga.UseCosmosDb(options =>
{
options.Client.ConnectionString = "AccountEndpoint=...;AccountKey=...";
options.DatabaseName = "myapp";
options.ContainerName = "sagas";
});
});

services.AddExcaliburSaga(saga =>
{
saga.UseDynamoDb(options =>
{
options.Connection.Region = "us-east-1";
options.TableName = "sagas";
});
});

services.AddExcaliburSaga(saga =>
{
saga.UseMongoDB(options =>
{
options.ConnectionString = "mongodb://localhost:27017";
options.DatabaseName = "myapp";
options.CollectionName = "sagas";
});
});

services.AddExcaliburSaga(saga =>
{
saga.UseFirestore(options =>
{
options.ProjectId = "my-project";
options.CollectionName = "sagas";
});
});

// Execution via ISagaOrchestrator
public class OrderController
{
private readonly ISagaOrchestrator _orchestrator;
private readonly OrderSaga _sagaDefinition;

public OrderController(ISagaOrchestrator orchestrator, OrderSaga sagaDefinition)
{
_orchestrator = orchestrator;
_sagaDefinition = sagaDefinition;
}

public string CreateOrder(CreateOrderRequest request)
{
var saga = _orchestrator.CreateSaga(
_sagaDefinition,
new OrderSagaData { OrderId = request.OrderId });

return saga.SagaId;
}
}

Compensation

When a step fails, the saga engine automatically compensates previously completed steps in reverse order:

Step 1: ReserveInventory  ✓ (completed)
Step 2: ProcessPayment ✓ (completed)
Step 3: ShipOrder ✗ (failed)

Compensation runs:
→ Compensate ProcessPayment (refund)
→ Compensate ReserveInventory (release stock)

Steps with CanCompensate = false are skipped during compensation. Place non-compensable steps last when possible.

Saga Status

Track saga progress through these statuses:

StatusMeaning
CreatedSaga initialized, not yet started
RunningExecuting steps
CompletedAll steps succeeded
FailedSteps failed, compensation finished or not possible
CompensatingRolling back completed steps
CompensatedAll compensations succeeded
CancelledManually cancelled
SuspendedPaused, awaiting external input
ExpiredTimed out

Step Status

StatusMeaning
NotStartedStep has not begun
RunningCurrently executing
SucceededCompleted successfully
FailedExecution failed
SkippedConditionally skipped
TimedOutExceeded step timeout

Compensation Status

StatusMeaning
NotRequiredStep did not need compensation
PendingAwaiting compensation
RunningCompensation in progress
SucceededCompensation completed
FailedCompensation failed
NotCompensableStep cannot be compensated (CanCompensate = false)

Managing Sagas

Use ISagaOrchestrator to manage saga lifecycle:

// Create a new saga (synchronous, requires saga definition)
var saga = orchestrator.CreateSaga(sagaDefinition, data);
var sagaId = saga.SagaId;

// Query saga state (requires type parameter)
var saga = await orchestrator.GetSagaAsync<OrderSagaData>(sagaId, ct);

// List active sagas
var active = await orchestrator.ListActiveSagasAsync(ct);

// Cancel a running saga (requires reason)
await orchestrator.CancelSagaAsync(sagaId, "User requested cancellation", ct);

Event-Driven Sagas

The step-based pattern shown above runs all steps in one call. For sagas that span independent microservices, use the event-driven pattern instead. The ISagaCoordinator processes incoming events to advance the saga one step at a time, persisting state between events. See Orchestration vs Choreography for the full event-driven pattern using SagaBase<T>.

Events must implement ISagaEvent:

using Excalibur.Dispatch.Abstractions;
using Excalibur.Dispatch.Abstractions.Delivery;
using Excalibur.Dispatch.Messaging;

// Define a saga event (must implement ISagaEvent)
public record OrderPlaced(Guid OrderId, string SagaId, string? StepId = null) : ISagaEvent;

// Handle saga events via the coordinator
public class OrderEventHandler : IEventHandler<OrderPlaced>
{
private readonly ISagaCoordinator _coordinator;
private readonly IMessageContextAccessor _contextAccessor;

public OrderEventHandler(
ISagaCoordinator coordinator,
IMessageContextAccessor contextAccessor)
{
_coordinator = coordinator;
_contextAccessor = contextAccessor;
}

public async Task HandleAsync(OrderPlaced @event, CancellationToken ct)
{
// Get context from accessor (set by pipeline during message processing)
var context = _contextAccessor.MessageContext
?? throw new InvalidOperationException("No message context available");

// ProcessEventAsync requires message context and ISagaEvent
await _coordinator.ProcessEventAsync(context, @event, ct);
}
}

SQL Server Saga Store

Persist saga state to SQL Server for durability:

services.AddExcaliburSaga(saga =>
{
saga.UseSqlServer(sql => { sql.ConnectionString = connectionString; })
.WithOrchestration()
.WithTimeouts();
});

The SQL Server store provides:

  • Durable saga state persistence
  • Concurrent saga execution safety
  • Idempotency checking via SqlServerSagaIdempotencyProvider
  • Correlation queries for saga lookup by business identifiers

Correlation Queries

Look up saga instances by business identifiers using ISagaCorrelationQuery:

// Find sagas by correlation ID (uses indexed computed column)
var sagas = await correlationQuery.FindByCorrelationIdAsync("order-123", ct);

// Find sagas by arbitrary JSON property (uses JSON_VALUE)
var sagas = await correlationQuery.FindByPropertyAsync("CustomerId", "cust-456", ct);

Register the SQL Server correlation query via the builder:

services.AddExcaliburSaga(saga =>
{
saga.UseSqlServer(sql => { sql.ConnectionString = connectionString; })
.WithCorrelationQuery();
});

The SQL Server implementation requires the 02-SagaCorrelationIndex.sql migration for optimal performance. Property names in FindByPropertyAsync are validated against a [GeneratedRegex] whitelist to prevent JSON path injection.

Idempotent Event Replay

SagaState automatically tracks processed event IDs to prevent duplicate command dispatch. When a saga event is delivered (including crash replays or concurrent duplicates), the SagaCoordinator calls SagaState.TryMarkEventProcessed(eventId) before executing the handler:

  • Returns true → event is new, process it normally
  • Returns false → event already processed, skip silently

The processed event set is bounded to 1,000 entries (oldest trimmed when exceeded) and persisted with the saga state. If a crash occurs between HandleAsync and SaveAsync, the event ID is lost from the set, and the event replays correctly on next delivery -- the correct behavior since side-effects were also lost.

NServiceBus Pattern

This follows the same idempotent replay pattern used by NServiceBus sagas, where saga state includes a list of handled message IDs.

Compensation Idempotency

The saga engine also checks ISagaIdempotencyProvider before executing compensation steps to prevent duplicate compensation during retries or event redelivery. This provides cross-process deduplication (complementing the in-process ProcessedEventIds check). This is wired automatically when using AdvancedSagaMiddleware.

For SQL Server persistence, register the idempotency provider:

services.AddExcaliburSaga(saga =>
{
saga.UseSqlServer(sql => { sql.ConnectionString = connectionString; })
.WithSqlServerIdempotency(sql => { sql.ConnectionString = connectionString; });
});

Retry Policies

Configure retry behavior by implementing ISagaRetryPolicy:

// No retries (default)
public ISagaRetryPolicy? RetryPolicy => null;

// Custom retry policy - implement ISagaRetryPolicy
public class ExponentialBackoffRetryPolicy : ISagaRetryPolicy
{
public int MaxAttempts { get; init; } = 3;
public TimeSpan Delay { get; init; } = TimeSpan.FromSeconds(2);

public bool ShouldRetry(Exception exception)
{
// Return true for transient failures
return exception is TimeoutException or HttpRequestException;
}
}

// Use in saga definition
public ISagaRetryPolicy? RetryPolicy => new ExponentialBackoffRetryPolicy
{
MaxAttempts = 3,
Delay = TimeSpan.FromSeconds(2)
};

The ISagaRetryPolicy interface defines:

MemberDescription
MaxAttemptsMaximum number of retry attempts
DelayDelay between retry attempts
ShouldRetry(Exception)Determines if an exception should trigger a retry

What's Next

See Also