Excalibur.Saga
10.0.0-alpha.21
dotnet add package Excalibur.Saga --version 10.0.0-alpha.21
NuGet\Install-Package Excalibur.Saga -Version 10.0.0-alpha.21
<PackageReference Include="Excalibur.Saga" Version="10.0.0-alpha.21" />
<PackageVersion Include="Excalibur.Saga" Version="10.0.0-alpha.21" />
<PackageReference Include="Excalibur.Saga" />
paket add Excalibur.Saga --version 10.0.0-alpha.21
#r "nuget: Excalibur.Saga, 10.0.0-alpha.21"
#:package Excalibur.Saga@10.0.0-alpha.21
#addin nuget:?package=Excalibur.Saga&version=10.0.0-alpha.21&prerelease
#tool nuget:?package=Excalibur.Saga&version=10.0.0-alpha.21&prerelease
Excalibur.Saga
Saga state persistence and coordination for distributed transactions in .NET applications.
Overview
Excalibur.Saga provides the foundation for implementing the Saga pattern - a way to manage distributed transactions across multiple services without requiring two-phase commit (2PC). It ensures data consistency in microservices architectures by coordinating compensating transactions when failures occur.
Installation
dotnet add package Excalibur.Saga
For SQL Server persistence:
dotnet add package Excalibur.Saga.SqlServer
Features
| Feature | Description |
|---|---|
| ISagaStore | Saga state persistence abstraction |
| SagaOptions | Configuration for timeouts, retries, concurrency |
| Compensation Tracking | Track compensation status per step |
| Retry Policies | Built-in exponential backoff and fixed delay |
| AOT Compatible | Full Native AOT support |
| Provider Agnostic | Pluggable storage backends |
Quick Start
1. Register Services
using Microsoft.Extensions.DependencyInjection;
services.AddExcalibur(x => x.AddSagas(options =>
{
options.MaxConcurrency = 10;
options.DefaultTimeout = TimeSpan.FromMinutes(30);
options.MaxAttempts = 3;
options.RetryDelay = TimeSpan.FromMinutes(1);
}));
// Add SQL Server persistence (optional)
services.AddExcalibur(excalibur => excalibur.AddSagas(saga =>
saga.UseSqlServer(sql => sql.ConnectionString(connectionString))));
2. Define Saga State
using Excalibur.Dispatch.Messaging;
public class OrderFulfillmentState : SagaState
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public decimal TotalAmount { get; set; }
// Step completion tracking
public bool OrderCreated { get; set; }
public bool PaymentProcessed { get; set; }
public bool InventoryReserved { get; set; }
public bool ShipmentScheduled { get; set; }
// Compensation tracking
public bool PaymentRefunded { get; set; }
public bool InventoryReleased { get; set; }
// Error information
public string? FailureReason { get; set; }
public DateTimeOffset? FailedAt { get; set; }
}
3. Implement Saga Orchestrator
using Excalibur.Dispatch.Messaging;
public class OrderFulfillmentSaga : Saga<OrderFulfillmentState>
{
public OrderFulfillmentSaga(
OrderFulfillmentState state,
IDispatcher dispatcher,
ILogger<OrderFulfillmentSaga> logger)
: base(state, dispatcher, logger)
{
}
public override bool HandlesEvent(object eventMessage)
{
return eventMessage is StartOrderFulfillment
or OrderCreated
or PaymentProcessed
or PaymentFailed
or InventoryReserved
or ShipmentScheduled;
}
public override async Task HandleAsync(
object eventMessage,
CancellationToken cancellationToken)
{
switch (eventMessage)
{
case StartOrderFulfillment start:
await HandleStart(start, cancellationToken);
break;
case OrderCreated created:
await HandleOrderCreated(created, cancellationToken);
break;
case PaymentProcessed processed:
await HandlePaymentProcessed(processed, cancellationToken);
break;
case PaymentFailed failed:
await HandlePaymentFailed(failed, cancellationToken);
break;
// ... additional handlers
}
}
private async Task HandleStart(
StartOrderFulfillment start,
CancellationToken cancellationToken)
{
State.OrderId = start.OrderId;
State.CustomerId = start.CustomerId;
State.TotalAmount = start.TotalAmount;
Logger.LogInformation("Starting saga for order {OrderId}", start.OrderId);
await Dispatcher.DispatchAsync(
new CreateOrder(start.OrderId, start.CustomerId, start.TotalAmount),
cancellationToken);
}
// ... additional handler methods
}
Core Concepts
Saga State
The SagaState base class provides essential tracking:
public abstract class SagaState
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
public Guid SagaId { get; set; } = Guid.NewGuid();
/// <summary>
/// Whether the saga has completed (successfully or via compensation).
/// </summary>
public bool Completed { get; set; }
/// <summary>
/// Current step in the saga workflow.
/// </summary>
public int CurrentStep { get; set; }
/// <summary>
/// When the saga was started.
/// </summary>
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
}
Saga Store
ISagaStore persists saga state across process restarts:
public interface ISagaStore
{
Task<TSagaState?> GetAsync<TSagaState>(
Guid sagaId,
CancellationToken cancellationToken)
where TSagaState : SagaState;
Task SaveAsync<TSagaState>(
TSagaState state,
CancellationToken cancellationToken)
where TSagaState : SagaState;
Task DeleteAsync(
Guid sagaId,
CancellationToken cancellationToken);
Task<IReadOnlyList<TSagaState>> GetPendingAsync<TSagaState>(
CancellationToken cancellationToken)
where TSagaState : SagaState;
}
Compensation Status
Track compensation state per step:
public enum CompensationStatus
{
/// <summary>
/// Step completed successfully, no compensation needed.
/// </summary>
NotRequired,
/// <summary>
/// Compensation is pending.
/// </summary>
Pending,
/// <summary>
/// Compensation is currently executing.
/// </summary>
Running,
/// <summary>
/// Compensation completed successfully.
/// </summary>
Succeeded,
/// <summary>
/// Compensation failed.
/// </summary>
Failed,
/// <summary>
/// Step cannot be compensated.
/// </summary>
NotCompensable
}
Configuration Options
SagaOptions
public class SagaOptions
{
/// <summary>
/// Maximum concurrent saga executions.
/// Default: 10
/// </summary>
public int MaxConcurrency { get; set; } = 10;
/// <summary>
/// Default timeout for saga steps.
/// Default: 30 minutes
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Maximum retry attempts before dead letter.
/// Default: 3
/// </summary>
public int MaxAttempts { get; set; } = 3;
/// <summary>
/// Delay between retry attempts.
/// Default: 1 minute
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromMinutes(1);
}
Retry Policies
Built-in retry strategies for transient failures:
// Exponential backoff: 1s, 2s, 4s...
var exponential = RetryPolicy.ExponentialBackoff(
maxAttempts: 3,
initialDelay: TimeSpan.FromSeconds(1));
// Fixed delay between retries
var fixed = RetryPolicy.FixedDelay(
maxAttempts: 3,
delay: TimeSpan.FromSeconds(5));
// Custom policy
var custom = new RetryPolicy
{
MaxAttempts = 5,
InitialDelay = TimeSpan.FromMilliseconds(500),
MaxDelay = TimeSpan.FromSeconds(30),
BackoffMultiplier = 2.0,
UseJitter = true // Prevents thundering herd
};
Saga Patterns
Orchestration Pattern
A central coordinator manages the saga workflow:
┌─────────────┐
│ Orchestrator│
└──────┬──────┘
│
├──► Step 1: Create Order
├──► Step 2: Process Payment
├──► Step 3: Reserve Inventory
└──► Step 4: Schedule Shipment
Use when:
- Complex workflows with conditional logic
- Need central visibility and monitoring
- Easier debugging requirements
Choreography Pattern
Services react to events without central coordination:
OrderPlaced ──► PaymentProcessed ──► InventoryReserved ──► ShipmentScheduled
│ │ │
▼ ▼ ▼
Payment Inventory Shipping
Service Service Service
Use when:
- Simple, linear workflows
- Loose coupling required
- High scalability needed
Compensation
When a step fails, previous steps must be compensated (rolled back):
public class ProcessPaymentStep : ISagaStep<OrderSagaData>
{
public string Name => "ProcessPayment";
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public RetryPolicy? RetryPolicy => RetryPolicy.ExponentialBackoff(3);
public bool CanCompensate => true;
public async Task<StepResult> ExecuteAsync(
SagaExecutionContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
var paymentId = await _gateway.ChargeAsync(
context.Data.CustomerId,
context.Data.Amount,
cancellationToken);
context.Data.PaymentId = paymentId;
return StepResult.Success();
}
public async Task<StepResult> CompensateAsync(
SagaExecutionContext<OrderSagaData> context,
CancellationToken cancellationToken)
{
// Refund the payment
await _gateway.RefundAsync(
context.Data.PaymentId,
cancellationToken);
return StepResult.Success();
}
}
Timeout and Retry
Configure timeouts and retries per step:
public class ExternalApiStep : ISagaStep<OrderSagaData>
{
public string Name => "CallExternalApi";
// Step times out after 45 seconds
public TimeSpan Timeout => TimeSpan.FromSeconds(45);
// Retry with exponential backoff + jitter
public RetryPolicy? RetryPolicy => new RetryPolicy
{
MaxAttempts = 4,
InitialDelay = TimeSpan.FromSeconds(1),
BackoffMultiplier = 2.0,
UseJitter = true
};
public bool CanCompensate => true;
// ... implementation
}
Integration with Event Sourcing
Sagas work seamlessly with event-sourced aggregates:
public class OrderSagaEventHandler : IEventHandler<OrderPlaced>
{
private readonly ISagaCoordinator _coordinator;
public async Task HandleAsync(
OrderPlaced @event,
CancellationToken cancellationToken)
{
// Start saga when order is placed
await _coordinator.StartAsync(new StartOrderFulfillment
{
SagaId = Guid.NewGuid().ToString(),
OrderId = @event.OrderId,
CustomerId = @event.CustomerId,
TotalAmount = @event.TotalAmount
}, cancellationToken);
}
}
SQL Server Implementation
For production use, add SQL Server persistence:
services.AddExcalibur(excalibur => excalibur.AddSagas(saga =>
saga.UseSqlServer(sql => sql.ConnectionString(connectionString))));
// Optional. These are the defaults, and they must match the table you provisioned:
// sql.SchemaName("dispatch")
// sql.TableName("sagas")
Provisioning the schema
The store does not create its table at runtime. Provision it before the first saga is
started, by running the script shipped inside the Excalibur.Saga.SqlServer package:
scripts/01-SagaSchema.sql
That script creates dispatch.sagas and its indexes, and it is the authoritative definition
of the table's shape. It is safe to re-run: each statement is guarded, and it also upgrades a
table created by an earlier version.
The DDL is deliberately not reproduced here. A copy in a README drifts out of step with the script the moment a column is added, and a consumer who follows the drifted copy gets a table the store cannot read.
Two properties of that schema are worth knowing before you provision it, because they affect how you operate the table:
- The tenant discriminator is part of the primary key, which is
(TenantId, SagaId). Sagas are correlated by a business key such as an order id, so two tenants can legitimately hold the sameSagaId; keyed onSagaIdalone, one tenant's saga would overwrite the other's. A row that is genuinely not tenant-scoped stores a reserved sentinel rather thanNULL. CompletedAtdrives retention. The purge deletes on that column, so it is indexed, and it is stored with its UTC offset.
Monitoring
Track saga execution with logging and metrics:
public class MonitoredSaga : Saga<OrderFulfillmentState>
{
public override async Task HandleAsync(
object eventMessage,
CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("Saga.HandleEvent");
activity?.SetTag("saga.id", State.SagaId);
activity?.SetTag("event.type", eventMessage.GetType().Name);
try
{
await base.HandleAsync(eventMessage, cancellationToken);
SagaMetrics.RecordSagaCompleted(nameof(MonitoredSaga));
}
catch (Exception ex)
{
SagaMetrics.RecordSagaFailed(nameof(MonitoredSaga));
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
Best Practices
DO
- Persist state before external calls - Recover from crashes
- Make steps idempotent - Handle duplicate deliveries
- Design compensations carefully - Not all actions can be undone
- Use correlation IDs - Track the entire distributed transaction
- Set appropriate timeouts - Prevent indefinite hanging
- Monitor saga health - Alert on stuck or failed sagas
DON'T
- Don't mix orchestration and choreography - Choose one per saga
- Don't store transient data in saga state - Only track step completion
- Don't make orchestrators do business logic - Delegate to services
- Don't forget compensation - Always plan for failure scenarios
- Don't use sagas for simple operations - Adds unnecessary complexity
Related Packages
| Package | Purpose |
|---|---|
Excalibur.Saga.SqlServer |
SQL Server saga store implementation |
Excalibur.Dispatch.Patterns |
Saga step abstractions and orchestration |
Excalibur.Dispatch.Abstractions |
Core saga interfaces |
Dispatch |
Message dispatching for saga events |
License
This project is multi-licensed under:
See LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Ben.Demystifier (>= 0.4.1)
- CloudNative.CloudEvents (>= 2.8.0)
- CloudNative.CloudEvents.SystemTextJson (>= 2.8.0)
- Cronos (>= 0.12.0)
- Dapper (>= 2.1.89)
- Excalibur.Data.Abstractions (>= 10.0.0-alpha.21)
- Excalibur.Dispatch (>= 10.0.0-alpha.21)
- Excalibur.Dispatch.Abstractions (>= 10.0.0-alpha.21)
- Excalibur.Hosting (>= 10.0.0-alpha.21)
- FluentValidation (>= 12.1.1)
- FluentValidation.DependencyInjectionExtensions (>= 12.1.1)
- IdentityModel (>= 7.0.0)
- JsonNet.ContractResolvers (>= 2.0.0)
- Medo.Uuid7 (>= 3.2.0)
- MemoryPack (>= 1.21.4)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Caching.Memory (>= 10.0.13)
- Microsoft.Extensions.Configuration (>= 10.0.13)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.13)
- Microsoft.Extensions.Configuration.CommandLine (>= 10.0.13)
- Microsoft.Extensions.Configuration.EnvironmentVariables (>= 10.0.13)
- Microsoft.Extensions.Configuration.Json (>= 10.0.13)
- Microsoft.Extensions.DependencyInjection (>= 10.0.13)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.13)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Hosting (>= 10.0.13)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Http (>= 10.0.13)
- Microsoft.Extensions.Logging (>= 10.0.13)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Logging.Console (>= 10.0.13)
- Microsoft.Extensions.ObjectPool (>= 10.0.13)
- Microsoft.Extensions.Options (>= 10.0.13)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.13)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.13)
- Microsoft.IdentityModel.Tokens (>= 8.17.0)
- Polly (>= 8.6.6)
- System.IdentityModel.Tokens.Jwt (>= 8.17.0)
- System.Threading.RateLimiting (>= 10.0.13)
NuGet packages (14)
Showing the top 5 NuGet packages that depend on Excalibur.Saga:
| Package | Downloads |
|---|---|
|
Excalibur.Data.MongoDB
MongoDB database provider implementation for Excalibur data access layer. PRE-RELEASE: the public API is not frozen and may change between pre-release builds. Not recommended for production use. Known issues: https://docs.excalibur-dispatch.dev/docs/known-issues |
|
|
Excalibur.Data.DynamoDb
AWS DynamoDB data provider implementation for Excalibur cloud-native data access. PRE-RELEASE: the public API is not frozen and may change between pre-release builds. Not recommended for production use. Known issues: https://docs.excalibur-dispatch.dev/docs/known-issues |
|
|
Excalibur.Data.Firestore
Google Cloud Firestore data provider implementation for Excalibur cloud-native data access. PRE-RELEASE: the public API is not frozen and may change between pre-release builds. Not recommended for production use. Known issues: https://docs.excalibur-dispatch.dev/docs/known-issues |
|
|
Excalibur.Data.CosmosDb
Azure Cosmos DB data provider implementation for Excalibur cloud-native data access. PRE-RELEASE: the public API is not frozen and may change between pre-release builds. Not recommended for production use. Known issues: https://docs.excalibur-dispatch.dev/docs/known-issues |
|
|
Excalibur.Saga.SqlServer
SQL Server implementation of the saga pattern for Excalibur, providing durable saga state persistence with optimistic concurrency control using SQL Server as the backing store. PRE-RELEASE: the public API is not frozen and may change between pre-release builds. Not recommended for production use. Known issues: https://docs.excalibur-dispatch.dev/docs/known-issues |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.0-alpha.21 | 204 | 11/1/2026 |
| 10.0.0-alpha.20 | 206 | 10/30/2026 |
| 10.0.0-alpha.19 | 206 | 10/27/2026 |
| 10.0.0-alpha.18 | 203 | 10/25/2026 |
| 10.0.0-alpha.17 | 210 | 10/23/2026 |
| 10.0.0-alpha.16 | 220 | 10/21/2026 |
| 10.0.0-alpha.15 | 222 | 10/11/2026 |
| 10.0.0-alpha.14 | 223 | 10/2/2026 |
| 10.0.0-alpha.13 | 203 | 9/29/2026 |
| 10.0.0-alpha.12 | 227 | 9/25/2026 |
| 10.0.0-alpha.11 | 233 | 9/15/2026 |
| 10.0.0-alpha.10 | 249 | 9/6/2026 |
| 10.0.0-alpha.9 | 235 | 8/31/2026 |
| 10.0.0-alpha.8 | 276 | 8/14/2026 |
| 10.0.0-alpha.7 | 279 | 8/13/2026 |
| 10.0.0-alpha.6 | 265 | 8/11/2026 |
| 10.0.0-alpha.5 | 280 | 8/10/2026 |
| 10.0.0-alpha.4 | 270 | 8/10/2026 |
| 3.0.0-alpha.216 | 242 | 6/30/2026 |
| 3.0.0-alpha.215 | 226 | 6/23/2026 |
Release notes and versioning policy: https://docs.excalibur-dispatch.dev/docs/migration/version-upgrades