The scenario every e-commerce team eventually hits

Checkout looks like a straight line: charge the card, reserve inventory, create the shipment. In reality it's a chain of independent services, and any link can fail after an earlier one has already committed. The specific, expensive failure: the payment gateway confirms the charge, then the inventory service finds the item was already sold to someone else in a race, or a warehouse integration times out. The customer is now charged for an order that cannot ship.

This is not rare. Any flow with more than one write across more than one service will eventually hit this ordering. The question worth asking in a design review isn't "could this happen" - it's "what exactly happens in our system when it does, today."

Option A: Two-Phase Commit with Seata

Apache Seata's AT mode lets you annotate the entire flow with @GlobalTransactional. Under the hood, each participating service's SQL statements are automatically shadowed into an UNDO_LOG table before they commit. If any branch of the global transaction fails, Seata's transaction coordinator tells every participant to replay its own undo log, reverting the change - automatically, without you writing a single compensating function by hand.

@GlobalTransactional
public void placeOrder(OrderRequest request) {
    Long orderId = orderService.create(request);      // branch 1
    paymentService.charge(orderId, request.total());  // branch 2
    inventoryService.reserve(orderId, request.items()); // branch 3
    // If reserve() throws, Seata automatically rolls back
    // branches 1 and 2 using their recorded UNDO_LOG entries.
}

What you give up: Seata's AT mode holds each participant's row locks for the duration of the global transaction (released on global commit or rollback), which is longer than a purely local transaction would hold them - real throughput cost under high concurrency on hot inventory rows.

Option B: Orchestrated SAGA with explicit compensations

The alternative: no automatic undo-log magic, just an orchestrator that calls each step and, on failure, explicitly calls the compensating action for every step that already committed - refund the payment, then cancel the order.

async function placeOrderSaga(request) {
  const order = await orderService.create(request);
  const payment = await paymentService.charge(order.id, request.total);

  try {
    await inventoryService.reserve(order.id, request.items);
  } catch (err) {
    await paymentService.refund(payment.id);   // compensate the charge
    await orderService.markFailed(order.id);   // compensate the order
    await notifyService.send({                 // tell the customer, don't leave them guessing
      type: 'OrderFailedRefunded',
      orderId: order.id,
      userId: request.userId,
    });
    throw err;
  }

  return order;
}

This is more code, but every step is visible and testable in isolation - useful when the compensation itself has business nuance (a partial refund, a store-credit fallback, a manual-review flag for high-value orders) that an automatic undo log can't express.

The step people skip: notifying the customer

A rollback that only fixes the database and never tells the customer is half-finished. If a payment was authorized and then refunded within the same request cycle, the customer should get one clear notice, not silence followed by a confusing "refund" line item they have to investigate themselves. This is exactly where the notification idempotency pattern from our other piece matters - the refund confirmation is itself a transaction-critical send that must not duplicate or go missing.

Choosing between them

FactorSeata 2PC/AT modeOrchestrated SAGA
Code to writeMinimal - one annotationExplicit per-step compensation
Lock durationHeld for whole global transactionOnly per local transaction
Business nuance in rollbackLimited - reverts SQL as-isFull control (partial refund, store credit, flags)
Operational footprintRequires running Seata serverNone extra - just your own services
Best fitJava-heavy stack, simple undo semanticsPolyglot stack, or nuanced business rules on failure

Key takeaways

  • Payment-succeeds-but-fulfillment-fails is a normal failure mode in any multi-service checkout, not an edge case worth ignoring.
  • Seata's @GlobalTransactional gives you automatic rollback at the cost of longer-held locks and an extra operational component.
  • Orchestrated SAGA compensations give you full control over the business logic of "undo," at the cost of writing that logic yourself.
  • A rollback isn't complete until the customer has been notified - idempotently, so the refund confirmation can't double-send either.
  • Test this path deliberately. Most teams discover their rollback gap the first time it happens in production, not in a design review.
If you've never actually forced this failure in staging - killed the inventory service mid-checkout and watched what happens to the charge - that's worth doing before your next high-traffic sale, not after.