.NET & C# Development · Lesson 173 of 229
Saga Pattern in .NET — Choreography vs Orchestration
Saga Pattern in .NET — Choreography vs Orchestration
Microservices don't share a database, so ACID transactions across services are impossible. The Saga pattern breaks a distributed transaction into a sequence of local transactions with compensating actions to undo completed steps when a later step fails.
The Problem
Placing an order touches three services:
1. Order Service — creates the order record
2. Payment Service — charges the customer
3. Inventory Service — reserves the stock
If step 3 fails after steps 1 and 2 succeeded:
- Database rollback: impossible (different databases)
- Leave inconsistent state: unacceptable
- Saga: compensate by reversing steps 2 and 1 in orderChoreography vs Orchestration
Choreography — event-driven, no central coordinator:
OrderService publishes OrderPlaced
→ PaymentService subscribes, charges card, publishes PaymentProcessed
→ InventoryService subscribes, reserves stock, publishes StockReserved
Pros: loose coupling, no single point of failure
Cons: hard to visualise flow, distributed debugging nightmare
Orchestration — central saga manager coordinates services:
SagaOrchestrator sends PlaceOrderCommand
→ tells PaymentService to charge card
→ tells InventoryService to reserve stock
→ if any step fails, sends compensating commands
Pros: single place to see the entire flow, easy to audit
Cons: orchestrator becomes a bottleneck if not statelessStep 1: Saga State Machine with MassTransit
// Install MassTransit with RabbitMQ and EF Core persistence
// <PackageReference Include="MassTransit.RabbitMQ" />
// <PackageReference Include="MassTransit.EntityFrameworkCore" />
// Saga state — persisted to database
public class OrderSagaState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; } // links all messages in this saga
public string CurrentState { get; set; } = "";
// Saga data accumulated as steps complete
public int OrderId { get; set; }
public int CustomerId { get; set; }
public decimal OrderTotal { get; set; }
public string? PaymentRef { get; set; }
public string? ReservationRef { get; set; }
public string? FailureReason { get; set; }
// Timeout scheduling
public Guid? PaymentTimeoutToken { get; set; }
public Guid? InventoryTimeoutToken { get; set; }
}// The state machine — defines all states, events, and transitions
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
// States
public State AwaitingPayment { get; private set; } = null!;
public State AwaitingInventory { get; private set; } = null!;
public State Completed { get; private set; } = null!;
public State Compensating { get; private set; } = null!;
public State Failed { get; private set; } = null!;
// Events (messages that drive the saga)
public Event<OrderPlaced> OrderPlaced { get; private set; } = null!;
public Event<PaymentProcessed> PaymentProcessed { get; private set; } = null!;
public Event<PaymentFailed> PaymentFailed { get; private set; } = null!;
public Event<StockReserved> StockReserved { get; private set; } = null!;
public Event<StockReservationFailed> StockReservationFailed { get; private set; } = null!;
public Event<OrderCancelled> OrderCancelled { get; private set; } = null!;
// Scheduled events (timeouts)
public Schedule<OrderSagaState, PaymentTimeout> PaymentSchedule { get; private set; } = null!;
public Schedule<OrderSagaState, InventoryTimeout> InventorySchedule { get; private set; } = null!;
public OrderSaga()
{
InstanceState(x => x.CurrentState);
// Correlate messages by OrderId
Event(() => OrderPlaced, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
Event(() => PaymentProcessed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
Event(() => PaymentFailed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
Event(() => StockReserved, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
Event(() => StockReservationFailed, x => x.CorrelateById(ctx => ctx.Message.CorrelationId));
// Timeout schedules
Schedule(() => PaymentSchedule, x => x.PaymentTimeoutToken, s => s.Delay = TimeSpan.FromMinutes(5));
Schedule(() => InventorySchedule, x => x.InventoryTimeoutToken, s => s.Delay = TimeSpan.FromMinutes(2));
// Initial state: order placed → request payment
Initially(
When(OrderPlaced)
.Then(ctx =>
{
ctx.Saga.OrderId = ctx.Message.OrderId;
ctx.Saga.CustomerId = ctx.Message.CustomerId;
ctx.Saga.OrderTotal = ctx.Message.Total;
})
.Publish(ctx => new ProcessPayment(ctx.Saga.CorrelationId, ctx.Saga.CustomerId, ctx.Saga.OrderTotal))
.Schedule(PaymentSchedule, ctx => new PaymentTimeout(ctx.Saga.CorrelationId))
.TransitionTo(AwaitingPayment));
// Payment succeeded → request inventory
During(AwaitingPayment,
When(PaymentProcessed)
.Then(ctx =>
{
ctx.Saga.PaymentRef = ctx.Message.PaymentReference;
})
.Unschedule(PaymentSchedule)
.Publish(ctx => new ReserveStock(ctx.Saga.CorrelationId, ctx.Saga.OrderId))
.Schedule(InventorySchedule, ctx => new InventoryTimeout(ctx.Saga.CorrelationId))
.TransitionTo(AwaitingInventory),
When(PaymentFailed)
.Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
.Unschedule(PaymentSchedule)
.Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Payment failed"))
.TransitionTo(Failed),
When(PaymentSchedule.Received)
.Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Payment timeout"))
.TransitionTo(Failed));
// Stock reserved → saga complete
During(AwaitingInventory,
When(StockReserved)
.Then(ctx => ctx.Saga.ReservationRef = ctx.Message.ReservationReference)
.Unschedule(InventorySchedule)
.Publish(ctx => new OrderFulfilled(ctx.Saga.CorrelationId, ctx.Saga.OrderId))
.TransitionTo(Completed)
.Finalize(),
When(StockReservationFailed)
.Unschedule(InventorySchedule)
.Then(ctx => ctx.Saga.FailureReason = ctx.Message.Reason)
// Compensate: refund the payment already taken
.Publish(ctx => new RefundPayment(ctx.Saga.CorrelationId, ctx.Saga.PaymentRef!))
.Publish(ctx => new CancelOrder(ctx.Saga.CorrelationId, ctx.Saga.OrderId, "Stock unavailable"))
.TransitionTo(Failed));
}
}Step 2: Message Contracts
// Commands — instruct a service to do something
public record ProcessPayment(Guid CorrelationId, int CustomerId, decimal Amount);
public record ReserveStock(Guid CorrelationId, int OrderId);
public record RefundPayment(Guid CorrelationId, string PaymentReference);
public record CancelOrder(Guid CorrelationId, int OrderId, string Reason);
// Events — announce that something happened
public record OrderPlaced(Guid CorrelationId, int OrderId, int CustomerId, decimal Total);
public record PaymentProcessed(Guid CorrelationId, string PaymentReference);
public record PaymentFailed(Guid CorrelationId, string Reason);
public record StockReserved(Guid CorrelationId, string ReservationReference);
public record StockReservationFailed(Guid CorrelationId, string Reason);
public record OrderFulfilled(Guid CorrelationId, int OrderId);
// Timeout markers
public record PaymentTimeout(Guid CorrelationId) : IScheduledMessage;
public record InventoryTimeout(Guid CorrelationId) : IScheduledMessage;Step 3: Service Consumer (Choreography side)
// Payment Service — listens for commands, publishes events
public class PaymentConsumer(
IPaymentGateway gateway,
IPublishEndpoint bus)
: IConsumer<ProcessPayment>
{
public async Task Consume(ConsumeContext<ProcessPayment> context)
{
try
{
var reference = await gateway.ChargeAsync(
context.Message.CustomerId,
context.Message.Amount,
context.CancellationToken);
await context.Publish(new PaymentProcessed(
context.Message.CorrelationId,
reference));
}
catch (PaymentDeclinedException ex)
{
await context.Publish(new PaymentFailed(
context.Message.CorrelationId,
ex.Message));
}
}
}Step 4: Register MassTransit with Saga Persistence
// Program.cs
builder.Services.AddMassTransit(x =>
{
// Persist saga state to PostgreSQL
x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
.EntityFrameworkRepository(r =>
{
r.ConcurrencyMode = ConcurrencyMode.Pessimistic;
r.AddDbContext<DbContext, SagaDbContext>((sp, opts) =>
opts.UseNpgsql(connectionString));
});
x.AddConsumer<PaymentConsumer>();
x.AddConsumer<InventoryConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host("rabbitmq://localhost");
cfg.ConfigureEndpoints(ctx);
});
});
// SagaDbContext
public class SagaDbContext(DbContextOptions<SagaDbContext> opts) : DbContext(opts)
{
protected override void OnModelCreating(ModelBuilder model)
{
model.AddInboxStateEntity(); // MassTransit inbox — exactly-once delivery
model.AddOutboxMessageEntity(); // MassTransit transactional outbox
model.AddOutboxStateEntity();
model.Entity<OrderSagaState>(e =>
{
e.HasKey(s => s.CorrelationId);
e.Property(s => s.CurrentState).HasMaxLength(64);
e.Property(s => s.PaymentRef).HasMaxLength(128);
e.Property(s => s.ReservationRef).HasMaxLength(128);
});
}
}Step 5: Starting the Saga
// Trigger the saga from the Order API
[ApiController]
[Route("api/orders")]
public class OrdersController(IBus bus, IOrderRepository orders) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request, CancellationToken ct)
{
// Create local order record
var order = await orders.CreateAsync(request, ct);
// Start the saga by publishing the initiating event
await bus.Publish(new OrderPlaced(
CorrelationId: NewId.NextGuid(), // MassTransit's globally unique ID
OrderId: order.Id,
CustomerId: order.CustomerId,
Total: order.Total),
ct);
return Accepted(new { orderId = order.Id });
}
}Compensating Transactions Pattern
Saga step sequence:
Step 1: Create Order Compensation: Cancel Order
Step 2: Charge Payment Compensation: Refund Payment
Step 3: Reserve Stock Compensation: Release Reservation
Step 4: Send Confirmation (no compensation needed — notification)
When Step 3 fails:
- Run compensation for Step 2: Refund Payment
- Run compensation for Step 1: Cancel Order
- Compensations must be idempotent — they may be called more than once
Idempotency rule: given the same input, compensation always produces
the same result and does not error if already applied.Interview Answer
"The Saga pattern handles distributed transactions across microservices by replacing a single ACID transaction with a sequence of local transactions plus compensating actions. When step N fails, the saga runs compensations for steps N-1 down to 1 in reverse order. Two flavours: choreography (services react to events — loosely coupled but hard to trace) and orchestration (a central state machine sends commands and handles responses — easier to reason about). In .NET, MassTransit's saga state machine implements orchestration: each state transition is explicit, state is persisted in PostgreSQL via EF Core, and timeouts prevent stuck sagas. The state machine uses CorrelationId to link all messages to one saga instance. Compensating transactions must be idempotent — a refund command that arrives twice should not refund twice. MassTransit's transactional outbox and inbox state entities ensure exactly-once delivery on each step. The key failure modes to handle: payment succeeds but inventory fails (refund), timeout on any step (cancel and refund), and message re-delivery (idempotency check)."