ZeroAlloc.Saga
Source-generated sagas for .NET: declare steps and their compensation on a partial class, and get a state machine with no reflection that runs under NativeAOT, on in-memory, EF Core, ZeroAlloc.ORM or Redis storage.
Install
dotnet add package ZeroAlloc.Saga
dotnet add package ZeroAlloc.Mediator.Generator
ZeroAlloc.Saga carries its own generator. Saga commands and events flow through
ZeroAlloc.Mediator, whose generator you reference directly. Packages lists the
optional stores and bridges.
Quick start
using Microsoft.Extensions.DependencyInjection;
using ZeroAlloc.Mediator;
using ZeroAlloc.Saga;
var services = new ServiceCollection().AddLogging().AddTransient<Warehouse>().AddTransient<Payments>();
services.AddSaga().WithCheckoutSaga(); // generated for the [Saga] class
await using var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();
await mediator.Publish(new OrderPlaced(42, 9.95m), default); // step 1 sends ReserveStock
await mediator.Publish(new StockReserved(42), default); // step 2 sends ChargeCard
await mediator.Publish(new PaymentDeclined(42), default); // compensation sends ReleaseStock
public sealed record OrderPlaced(int OrderId, decimal Total) : INotification;
public sealed record StockReserved(int OrderId) : INotification;
public sealed record PaymentCharged(int OrderId) : INotification;
public sealed record PaymentDeclined(int OrderId) : INotification;
public readonly record struct ReserveStock(int OrderId) : IRequest;
public readonly record struct ReleaseStock(int OrderId) : IRequest;
public readonly record struct ChargeCard(int OrderId, decimal Total) : IRequest;
public readonly record struct ShipOrder(int OrderId) : IRequest;
[Saga]
public partial class CheckoutSaga
{
public int OrderId { get; private set; }
public decimal Total { get; private set; }
[CorrelationKey] public int Key(OrderPlaced e) => e.OrderId;
[CorrelationKey] public int Key(StockReserved e) => e.OrderId;
[CorrelationKey] public int Key(PaymentCharged e) => e.OrderId;
[CorrelationKey] public int Key(PaymentDeclined e) => e.OrderId;
[Step(Order = 1, Compensate = nameof(Release))]
public ReserveStock Reserve(OrderPlaced e) { (OrderId, Total) = (e.OrderId, e.Total); return new(OrderId); }
[Step(Order = 2, CompensateOn = typeof(PaymentDeclined))]
public ChargeCard Charge(StockReserved e) => new(OrderId, Total);
[Step(Order = 3)]
public ShipOrder Ship(PaymentCharged e) => new(OrderId);
public ReleaseStock Release() => new(OrderId);
}
public sealed class Warehouse : IRequestHandler<ReserveStock, Unit>, IRequestHandler<ReleaseStock, Unit>, IRequestHandler<ShipOrder, Unit>
{
public ValueTask<Unit> Handle(ReserveStock c, CancellationToken ct) => Log($"Reserved stock for {c.OrderId}");
public ValueTask<Unit> Handle(ReleaseStock c, CancellationToken ct) => Log($"Released stock for {c.OrderId}");
public ValueTask<Unit> Handle(ShipOrder c, CancellationToken ct) => Log($"Shipped {c.OrderId}");
internal static ValueTask<Unit> Log(string what) { Console.WriteLine(what); return new(Unit.Value); }
}
public sealed class Payments : IRequestHandler<ChargeCard, Unit>
{
public ValueTask<Unit> Handle(ChargeCard c, CancellationToken ct) => Warehouse.Log($"Charging {c.Total}");
}
It prints Reserved stock for 42, Charging 9.95 and Released stock for 42. A saga that reaches a terminal state is removed from the store automatically. Concepts explains the lifecycle and what the generator emits.
Contents
| Page | What it covers |
|---|---|
| Concepts | The saga lifecycle and what the generator emits |
| Correlation | Routing each event to its saga instance with [CorrelationKey] |
| Compensation | Rolling back completed steps on a failure event or on demand |
| Packages | Install, optional packages and version floors |
| EF Core persistence | The durable store on your DbContext |
| Redis persistence | The durable store on Redis |
| Outbox bridge | Step commands committed with the saga state |
| Outbox on Redis | Atomic dispatch under the Redis store |
| Resilience | Retry, timeout, circuit-breaker and rate-limit around dispatch |
| Diagnostics | Every generator diagnostic and its fix |
| Performance | Allocation budgets and supported state shapes |
| Samples | Runnable projects, including the NativeAOT smoke tests |
| Limitations and roadmap | What is not supported yet |
| Migrating to v4 | Upgrading the outbox bridge to ZeroAlloc.Outbox 3 |
| Release highlights | The what's new notes from earlier releases |