Saga Orchestration
A saga manages a long-running business process that spans multiple services and messages. The orchestration style uses a central SagaOrchestrator<TSagaState> that explicitly coordinates each step — sending commands, reacting to events, handling timeouts, and compensating on failure.
When to use orchestration
Use orchestration when:
- The workflow has a clear sequence of steps with explicit dependencies
- You need timeout and compensation logic (rollback on failure)
- You want a single class that makes the full flow readable
- The number of participating services is known upfront
For looser coupling where each service reacts to events independently, see Saga Choreography.
Defining a saga
Create a POCO state class and an orchestrator:
// The state is serialized to JSON and persisted between steps.
public class OrderSagaState
{
public Guid OrderId { get; set; }
public Guid? PaymentId { get; set; }
public bool PaymentConfirmed { get; set; }
public bool StockReserved { get; set; }
}
public class OrderSaga : SagaOrchestrator<OrderSagaState>
{
protected override void Configure(ISagaBuilder<OrderSagaState> builder)
{
builder
.StartWith<OrderPlaced>(async (evt, state, ctx) =>
{
state.OrderId = evt.OrderId;
await ctx.SendCommandAsync(new ProcessPayment(evt.OrderId, evt.Total));
})
.Then<PaymentReceived>(async (evt, state, ctx) =>
{
state.PaymentId = evt.PaymentId;
state.PaymentConfirmed = true;
await ctx.SendCommandAsync(new ReserveStock(state.OrderId, evt.Sku, evt.Quantity));
})
.Then<StockReserved>((evt, state, ctx) =>
{
state.StockReserved = true;
ctx.Complete(); // saga is done
return Task.CompletedTask;
})
.CompensateWith<PaymentFailed>(async (evt, state, ctx) =>
{
await ctx.SendCommandAsync(new CancelPayment(state.OrderId, evt.Reason));
})
.ExpireAfter(TimeSpan.FromMinutes(30))
.OnTimeout(async (state, ctx) =>
{
await ctx.SendCommandAsync(new CancelPayment(state.OrderId, "Timed out"));
});
}
}
Registering the saga
services.AddOpinionatedEventingSagas();
services.AddSaga<OrderSaga>();
AddSaga<T> discovers the event types handled by the orchestrator and automatically registers IEventHandler<TEvent> for each one. No manual adapter registration is needed.
How it works
Starting a saga
When the framework receives an event registered with StartWith<TEvent>, it:
- Creates a new
OrderSagaStateinstance - Persists a
SagaStaterow withStatus = Active - Invokes the
StartWithhandler - Saves the updated state
The saga's CorrelationId is taken from the incoming event's CorrelationId. All subsequent commands and events in the chain carry the same CorrelationId, which is how the framework correlates them back to the right saga instance.
Continuing a saga
When subsequent events arrive (registered with Then<TEvent>), the framework:
- Looks up the existing
SagaStatebyCorrelationId - Deserializes the persisted
StateJSON back intoTSagaState - Invokes the handler
- Serializes the updated state and persists it
Completing a saga
Call ctx.Complete() inside any handler to mark the saga as Completed. No further events will be processed for this instance.
ISagaContext
The ISagaContext passed to every handler provides:
public interface ISagaContext
{
Guid CorrelationId { get; }
Task SendCommandAsync<TCommand>(TCommand command, CancellationToken ct = default)
where TCommand : ICommand;
Task PublishEventAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : IEvent;
void Complete();
}
All messages sent through ISagaContext go through the outbox — they are written atomically with the saga state update.
Timeout and compensation
Setting a timeout
builder.ExpireAfter(TimeSpan.FromMinutes(30));
// or — inject TimeProvider so the clock is controllable in tests
builder.ExpireAt(timeProvider.GetUtcNow().AddDays(1));
The SagaTimeoutWorker background service polls at SagaOptions.TimeoutCheckInterval (default: 30 seconds) for sagas whose ExpiresAt is in the past and Status is still Active.
Compensation
Register compensation handlers with CompensateWith<TEvent>:
builder.CompensateWith<PaymentFailed>(async (evt, state, ctx) =>
{
await ctx.SendCommandAsync(new CancelPayment(state.OrderId, evt.Reason));
});
When a compensation event arrives, the framework transitions the saga to Compensating and runs the handler. Compensation handlers run in reverse registration order — last registered, first executed.
Saga status lifecycle
Active
├── ctx.Complete() ──────────────────────→ Completed
├── CompensateWith<T> handler invoked ──→ Compensating
│ ├── success ───────────────────────→ Completed
│ └── failure ───────────────────────→ Failed
└── ExpiresAt reached ───────────────────→ TimedOut
└── OnTimeout handler runs
└── compensation if needed ──→ Completed / Failed
Custom correlation
By default the framework correlates events to sagas via the CorrelationId in the message envelope. If you need to correlate by a property in the event payload, use CorrelateBy:
builder.CorrelateBy<PaymentReceived>(evt => evt.OrderId.ToString());
Persisting state: EF Core
Saga state is stored in the saga_states table. Configure it in your DbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplySagaStateConfiguration();
}
And register the EF Core store:
services.AddOpinionatedEventingEntityFramework<AppDbContext>();
Testing sagas
Use InMemorySagaStateStore from OpinionatedEventing.Testing to test saga logic without a database:
var store = new InMemorySagaStateStore();
services.AddSingleton<ISagaStateStore>(store);
Use FakeTimeProvider to control the clock when testing timeout behaviour:
var clock = new FakeTimeProvider();
services.AddSingleton<TimeProvider>(clock);
clock.Advance(TimeSpan.FromMinutes(31)); // trigger timeout