Two-phase commit works fine inside a single database. The moment a "transaction" spans an order service, a payment service, and an inventory service — each with its own database — a distributed coordinator holding locks across all three becomes a single point of failure and a throughput ceiling. The saga pattern trades atomicity for a sequence of local transactions, each with a defined way to undo it.
- A saga is a sequence of local transactions, each in its own service, with a compensating action defined for every step that needs to be undone if a later step fails.
- Choreography (services react to each other's events) fits sagas with a handful of steps; orchestration (a central coordinator) fits longer, more complex workflows where you need visibility into where a transaction currently stands.
- Compensation isn't automatic like a database rollback — you design and implement the "undo" for every step explicitly, up front.
- Every step and every compensating action must be idempotent. Message redelivery and retries mean each one will, eventually, run more than once.
Why two-phase commit doesn't survive contact with microservices
2PC requires a coordinator to hold every participant in a prepared, locked state until all of them confirm — if the coordinator crashes mid-transaction, participants can be left blocked indefinitely. That's tolerable inside one database's own internals; it's a liability across service and network boundaries where partial failure is the normal case, not the exception.
Choreography: services react to events
Each service publishes an event when its local transaction completes, and listens for the events it cares about. No central coordinator — the saga's logic is distributed across the services themselves.
// OrderService: starts the saga
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.Lines);
await _orders.SaveAsync(order);
await _bus.Publish(new OrderPlaced(order.Id, order.Total, order.CustomerId));
}
// PaymentService: reacts to OrderPlaced
public async Task Handle(OrderPlaced evt)
{
var result = await _payments.Charge(evt.CustomerId, evt.Total);
if (result.Success)
await _bus.Publish(new PaymentCaptured(evt.OrderId));
else
await _bus.Publish(new PaymentFailed(evt.OrderId));
}
// OrderService: compensates if payment fails
public async Task Handle(PaymentFailed evt)
{
await _orders.MarkCancelled(evt.OrderId);
await _bus.Publish(new OrderCancelled(evt.OrderId));
}
Choreography keeps each service simple and decoupled, but past four or five steps it gets genuinely hard to answer "where does order 9931 currently stand" without reconstructing the flow from an event log across every service involved.
Orchestration: a coordinator owns the flow
An orchestrator (often a saga state machine, like the one MassTransit provides) explicitly owns the sequence, issues commands to each service, and reacts to their replies. The workflow is visible in one place instead of scattered across handlers.
public class OrderSaga : MassTransitStateMachine<OrderState>
{
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 Event<OrderPlaced> OrderPlaced { get; private set; } = null!;
public Event<PaymentCaptured> PaymentCaptured { get; private set; } = null!;
public Event<PaymentFailed> PaymentFailed { get; private set; } = null!;
public Event<InventoryReserved> InventoryReserved { get; private set; } = null!;
public OrderSaga()
{
InstanceState(x => x.CurrentState);
Initially(
When(OrderPlaced)
.Then(ctx => ctx.Saga.OrderId = ctx.Message.OrderId)
.PublishAsync(ctx => ctx.Init<ChargePayment>(new { ctx.Saga.OrderId }))
.TransitionTo(AwaitingPayment));
During(AwaitingPayment,
When(PaymentCaptured)
.PublishAsync(ctx => ctx.Init<ReserveInventory>(new { ctx.Saga.OrderId }))
.TransitionTo(AwaitingInventory),
When(PaymentFailed)
.PublishAsync(ctx => ctx.Init<CancelOrder>(new { ctx.Saga.OrderId }))
.TransitionTo(Compensating));
During(AwaitingInventory,
When(InventoryReserved)
.TransitionTo(Completed));
}
}
The state machine makes the happy path and every compensating branch explicit and inspectable — you can query "which orders are stuck in AwaitingPayment" directly, which is much harder to answer in a pure choreography model.
Designing compensation, not just the happy path
For every step that changes state, define its inverse before you write the step itself: charge payment → refund payment; reserve inventory → release inventory; send confirmation email → nothing to compensate, some steps genuinely have no meaningful undo and that's fine as long as you've decided that deliberately, not by omission.
Idempotency is not optional
Message brokers give you at-least-once delivery, which means every handler will occasionally see the same message twice. Without an idempotency key, a retried "charge payment" step charges the customer twice.
public async Task Handle(ChargePayment cmd)
{
if (await _payments.AlreadyProcessed(cmd.IdempotencyKey))
return; // already handled -- safe to no-op on redelivery
var result = await _payments.Charge(cmd.CustomerId, cmd.Amount, cmd.IdempotencyKey);
await _payments.RecordProcessed(cmd.IdempotencyKey);
}
Observability isn't optional either
A saga that fails silently in step four of seven is worse than the distributed-transaction problem it was meant to solve — you now have inconsistent state across three services and no clear signal that it happened. Every saga step should emit a trace span and a structured log entry; see our OpenTelemetry post for how we wire that up so a stuck saga shows up as a gap in a trace, not a support ticket three weeks later.