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.

Key takeaways
  • 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.

Order Service publishes Payment Service reacts & publishes Inventory Service reacts & completes OrderPlaced PaymentCaptured InventoryReserved PaymentFailed → compensate (MarkCancelled)
Choreography — each service reacts to the previous service's event; compensation flows directly back to Order Service
csharp
// 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.

Saga Orchestrator OrderSaga state machine Order Service Payment Service Inventory Service CancelOrder (compensate) ChargePayment PaymentCaptured PaymentFailed ReserveInventory InventoryReserved saga starts on OrderPlaced
Orchestration — the saga state machine owns every command and reply, including the compensating CancelOrder path
csharp
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.

They can coexistChoreography and orchestration aren't mutually exclusive across a whole system — we've shipped systems where a three-step checkout flow uses choreography (it's simple enough) while a longer onboarding workflow with a dozen steps uses an explicit orchestrator, because visibility into stuck workflows mattered more there.

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.

csharp
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.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.