The saga pattern coordinates a business workflow across local transactions in separate services. Each completed step records its result, and failures can trigger compensating actions such as releasing inventory. A saga exposes intermediate states and needs durable progress, reliable messages, idempotent operations, and a way to resolve uncertain outcomes.
What Problem Does a Saga Solve?
An order is awaiting payment when the payment request times out. The payment may have succeeded even though the response never reached the caller. There is no database rollback that can tell the payment provider to forget the request.
The workflow needs a stored state and a recovery decision. It can query the provider using the operation ID, retry with the same idempotency key where supported, or escalate for reconciliation. Treating every timeout as a declined payment creates incorrect compensation.
The distributed transaction comparison explains when compatible resource managers can coordinate an atomic commit. A saga uses a different boundary: each service commits independently, and the workflow manages the consequences. When all relevant writes fit in one database transaction, that local transaction is usually simpler.
Local Transactions and Compensation
Consider an order flow that creates a pending order, reserves inventory, requests payment, and confirms the order. A confirmed payment decline requires releasing the reservation and cancelling the pending order. An unknown payment outcome requires reconciliation before that decision.
A compensation is a new business operation. Releasing a reservation can fail and need retry; refunding a successful charge may not reverse fees or notifications. Define which completed steps can be compensated and how to recover when compensation itself fails.
Choreography: Services React to Events
In choreography, each service owns its next-step decision.
The order service publishes OrderPlaced, inventory reacts by reserving stock, and payment reacts to InventoryReserved.
The order service eventually consumes the payment result and changes the order status.
Every database change and its outgoing event need the same local transaction through an outbox. Saving a reservation and then publishing its result directly leaves a crash window that can stall the workflow forever. Consumer deduplication must cover the incoming message and business update too.
Choreography works when the flow remains easy to reconstruct from those handlers. As branches and compensations grow, tracing the current workflow state across several services becomes harder. The orchestration versus choreography guide compares those operational costs.
Orchestration: Persist the Workflow State
In orchestration, a coordinator stores progress and sends commands to the services that own each operation. Those services return result events. The coordinator does not gain a distributed database transaction; it gains an explicit record of what happened and what should happen next.
MassTransit implements persistent state machines for this style of workflow. The following coordinator targets .NET 10 and MassTransit 8.5.11. It uses PostgreSQL for saga state and a transactional consumer outbox for outgoing commands.
Create the project and install the packages:
dotnet new console -n OrderSagaDemo -f net10.0
cd OrderSagaDemo
dotnet add package Microsoft.Extensions.Hosting --version 10.0.0
dotnet add package MassTransit.RabbitMQ --version 8.5.11
dotnet add package MassTransit.EntityFrameworkCore --version 8.5.11
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0
Use the PostgreSQL and RabbitMQ compose.yaml from the outbox example, or equivalent local services.
The coordinator below creates a separate order_sagas database for demonstration.
Production deployments should apply reviewed EF migrations before starting the service.
Define Contracts and States
Put these contracts and state class in Contracts.cs.
Command handlers use OrderId to load their service's required data or reservation, so the coordinator does not store the inventory model:
using MassTransit;
namespace OrderSagaDemo;
public record OrderPlaced(Guid OrderId, decimal TotalAmount);
public record ReserveInventory(Guid OrderId);
public record InventoryReserved(Guid OrderId);
public record InventoryRejected(Guid OrderId);
public record ProcessPayment(Guid OrderId, decimal TotalAmount);
public record PaymentCompleted(Guid OrderId);
public record PaymentDeclined(Guid OrderId);
public record ReleaseInventory(Guid OrderId);
public record InventoryReleased(Guid OrderId);
public record ConfirmOrder(Guid OrderId);
public record OrderConfirmed(Guid OrderId);
public record CancelOrder(Guid OrderId);
public record OrderCancelled(Guid OrderId);
public class OrderSagaState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
}
PaymentDeclined means the payment service established that the payment was rejected.
It must not emit that event merely because a provider call timed out.
The payment service should persist and reconcile uncertain attempts using a stable provider idempotency key.
Add OrderSaga.cs:
using MassTransit;
namespace OrderSagaDemo;
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
public State AwaitingInventory { get; private set; } = null!;
public State AwaitingPayment { get; private set; } = null!;
public State Compensating { get; private set; } = null!;
public State AwaitingConfirmation { get; private set; } = null!;
public State AwaitingCancellation { get; private set; } = null!;
public State Completed { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<OrderPlaced> Placed { get; private set; } = null!;
public Event<InventoryReserved> Reserved { get; private set; } = null!;
public Event<InventoryRejected> Rejected { get; private set; } = null!;
public Event<PaymentCompleted> Paid { get; private set; } = null!;
public Event<PaymentDeclined> Declined { get; private set; } = null!;
public Event<InventoryReleased> Released { get; private set; } = null!;
public Event<OrderConfirmed> Confirmed { get; private set; } = null!;
public Event<OrderCancelled> Cancelled { get; private set; } = null!;
public OrderSaga()
{
InstanceState(x => x.CurrentState);
Event(() => Placed, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Reserved, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Rejected, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Paid, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Declined, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Released, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Confirmed, e => e.CorrelateById(c => c.Message.OrderId));
Event(() => Cancelled, e => e.CorrelateById(c => c.Message.OrderId));
Initially(When(Placed)
.Then(c => c.Saga.TotalAmount = c.Message.TotalAmount)
.Send(new Uri("queue:reserve-inventory"),
c => new ReserveInventory(c.Saga.CorrelationId))
.TransitionTo(AwaitingInventory));
During(AwaitingInventory,
Ignore(Placed),
When(Reserved)
.Send(new Uri("queue:process-payment"),
c => new ProcessPayment(c.Saga.CorrelationId, c.Saga.TotalAmount))
.TransitionTo(AwaitingPayment),
When(Rejected)
.Send(new Uri("queue:cancel-order"),
c => new CancelOrder(c.Saga.CorrelationId))
.TransitionTo(AwaitingCancellation));
During(AwaitingPayment,
Ignore(Reserved),
When(Paid)
.Send(new Uri("queue:confirm-order"),
c => new ConfirmOrder(c.Saga.CorrelationId))
.TransitionTo(AwaitingConfirmation),
When(Declined)
.Send(new Uri("queue:release-inventory"),
c => new ReleaseInventory(c.Saga.CorrelationId))
.TransitionTo(Compensating));
During(Compensating,
Ignore(Declined),
When(Released)
.Send(new Uri("queue:cancel-order"),
c => new CancelOrder(c.Saga.CorrelationId))
.TransitionTo(AwaitingCancellation));
During(AwaitingConfirmation,
Ignore(Paid),
When(Confirmed).TransitionTo(Completed));
During(AwaitingCancellation,
Ignore(Rejected),
Ignore(Released),
When(Cancelled).TransitionTo(Failed));
During(Completed, Ignore(Confirmed), Ignore(Paid), Ignore(Placed));
During(Failed, Ignore(Cancelled), Ignore(Declined), Ignore(Placed));
}
}
The coordinator waits for OrderConfirmed or OrderCancelled before entering a terminal state.
Sending the command alone only proves that the next action was requested.
The terminal rows remain stored here; define a retention policy before deleting completed saga history.
Wire Persistence and the Consumer Outbox
Create OrderSagaDbContext.cs with the state mapping and outbox entities:
using MassTransit;
using MassTransit.EntityFrameworkCoreIntegration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OrderSagaDemo;
public class OrderSagaMap : SagaClassMap<OrderSagaState>
{
protected override void Configure(
EntityTypeBuilder<OrderSagaState> entity, ModelBuilder model)
{
entity.Property(x => x.CurrentState).HasMaxLength(64);
entity.Property(x => x.TotalAmount).HasPrecision(18, 2);
}
}
public class OrderSagaDbContext(DbContextOptions<OrderSagaDbContext> options)
: SagaDbContext(options)
{
protected override IEnumerable<ISagaClassMap> Configurations
{
get { yield return new OrderSagaMap(); }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
}
}
Replace Program.cs with the host registration:
using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OrderSagaDemo;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<OrderSagaDbContext>(options =>
options.UseNpgsql(
"Host=localhost;Database=order_sagas;Username=postgres;Password=postgres"));
builder.Services.AddMassTransit(x =>
{
x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
.EntityFrameworkRepository(r =>
{
r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
r.ExistingDbContext<OrderSagaDbContext>();
r.UsePostgres();
});
x.AddEntityFrameworkOutbox<OrderSagaDbContext>(o => o.UsePostgres());
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h =>
{
h.Username("guest");
h.Password("guest");
});
cfg.ReceiveEndpoint("order-saga", e =>
{
e.UseMessageRetry(r => r.Intervals(100, 500, 1000));
e.UseEntityFrameworkOutbox<OrderSagaDbContext>(context);
e.ConfigureSaga<OrderSagaState>(context);
});
});
});
using var host = builder.Build();
await using (var scope = host.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<OrderSagaDbContext>();
await db.Database.EnsureCreatedAsync();
}
await host.RunAsync();
Run it with dotnet run.
This starts the coordinator; inventory, payment, and order command handlers are separate services that must provision the named queues and publish their result contracts.
Deploy these endpoints before the order service starts publishing OrderPlaced events.
The saga repository and consumer outbox use the same DbContext. That lets the transactional consumer outbox coordinate the state transition, inbox record, and outgoing commands. The retry policy addresses short transient failures; exhausted messages still need error-queue monitoring and recovery.
Handle Duplicates, Missing Results, and Timeouts
A saga state machine does not automatically buffer an event until a future state can handle it. Unhandled events normally fault, and messages for missing instances need an explicit policy. The sample ignores selected expected duplicate results; add rules for the duplicate and out-of-order cases your contracts permit.
Every command handler must also be idempotent. Reserve inventory using a unique operation or reservation ID, with that uniqueness enforced in the same transaction as the stock update. A preliminary "does this reservation exist?" query alone races with another delivery.
Database inbox deduplication does not make external side effects transactional. Payment calls need a stable provider idempotency key and persisted reconciliation state. Redelivering a command after a timeout must not issue a new logical charge.
The coordinator above demonstrates the confirmed-success and confirmed-failure paths. Before production use, add durable deadlines for pending states, payment-outcome reconciliation, handling for exhausted command retries, and alerts for failed compensation. The MassTransit saga walkthrough provides additional state-machine context.
Test restart recovery as well as the happy path. Check that a crash between persisting state and delivering a command does not lose that command, and that duplicate results cannot repeat a charge or reservation. Observe stuck saga age alongside message queue failure metrics.
Summary
Use a saga when a business process spans local transactions and the business can accept intermediate states. Define compensation for completed steps, reconciliation for uncertain outcomes, and terminal states that reflect acknowledged results.
Persist state and outgoing messages together, make every step idempotent, and give operators a way to recover stalled workflows. Those behaviors determine whether a saga remains reliable after the first timeout or restart.
Frequently Asked Questions
What is the Saga pattern?
A saga is a sequence of local transactions across multiple services. Each step updates its own database and triggers the next step, and failures can trigger compensating actions for completed steps. Compensation is a new business operation and can itself fail.
What is the difference between choreography and orchestration?
In choreography, each service reacts to events and decides what to do next, with no central coordinator. In orchestration, a central saga orchestrator sends commands to services and tracks the state of the whole flow.
Is the Saga pattern the same as a distributed transaction?
No. A distributed transaction (two-phase commit) locks resources across services to get atomicity. A saga uses separate local transactions, permits intermediate states, and uses business compensation rather than a distributed rollback.
What is a compensating transaction?
A compensating transaction semantically undoes a completed saga step, such as refunding a payment or releasing reserved inventory. It is a new operation, not a database rollback, so it must be designed explicitly.
When should I avoid the Saga pattern?
Avoid sagas when the operations live in one database, where a regular transaction is simpler and stronger. Sagas only pay off when a business process spans multiple services with separate data stores.

