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.
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
| Factor | Seata 2PC/AT mode | Orchestrated SAGA |
|---|---|---|
| Code to write | Minimal - one annotation | Explicit per-step compensation |
| Lock duration | Held for whole global transaction | Only per local transaction |
| Business nuance in rollback | Limited - reverts SQL as-is | Full control (partial refund, store credit, flags) |
| Operational footprint | Requires running Seata server | None extra - just your own services |
| Best fit | Java-heavy stack, simple undo semantics | Polyglot 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
@GlobalTransactionalgives 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.
Zetrixweb