Why @Transactional stops being enough
Inside a monolith, @Transactional (Spring) or an equivalent ORM transaction wraps every statement in
one database connection. If any step throws, the database rolls back everything - order row, inventory
decrement, ledger entry - atomically, because it's all one connection against one database. That's ACID:
atomicity, consistency, isolation, durability, and a single database engine enforces all four for you.
The moment you split "place order" across an Order Service, Inventory Service, and Payment Service - each
with its own database - @Transactional on any single service can only protect that service's
own writes. There is no longer one database to roll back. A payment can succeed in the Payment Service's
database while the Inventory Service's decrement fails independently, and no annotation on either service
knows about the other's outcome.
What a SAGA actually is
A SAGA breaks one distributed transaction into a sequence of local transactions, each of which commits immediately in its own service's database. If a later step fails, the SAGA doesn't roll back the earlier steps in the database sense - it can't, they're already committed elsewhere. Instead, it runs compensating transactions: explicit, business-level "undo" operations that reverse the effect of each already-committed step, in reverse order.
Choreographed SAGA
Each service publishes an event when its local transaction commits; the next service subscribes and reacts. No central coordinator - simpler to start, harder to trace as steps grow.
Orchestrated SAGA
A central orchestrator explicitly calls each service in sequence and issues compensating calls on failure. More code up front, but the entire flow is readable in one place - easier to operate at scale.
Worked example: order → payment → inventory
Orchestrated SAGA pseudocode for a typical checkout flow, with the compensating path spelled out explicitly:
class CreateOrderSaga {
async execute(orderRequest) {
const order = await orderService.create(orderRequest); // step 1: local commit
try {
const payment = await paymentService.charge(order.total); // step 2: local commit
try {
await inventoryService.reserve(order.items); // step 3: local commit
} catch (inventoryErr) {
await paymentService.refund(payment.id); // compensate step 2
await orderService.cancel(order.id); // compensate step 1
throw inventoryErr;
}
} catch (paymentErr) {
await orderService.cancel(order.id); // compensate step 1
throw paymentErr;
}
}
}The rule that matters: every forward step in a SAGA needs a corresponding compensating action defined before you write the happy path. If you can't write "how do we undo this" for a step, you don't have a SAGA yet - you have an optimistic hope that nothing downstream fails.
@GlobalTransactional (Seata) as the middle ground
Frameworks like Apache Seata offer
an annotation-driven alternative that feels closer to @Transactional: wrap the whole flow with
@GlobalTransactional, and Seata's AT mode automatically records "undo logs" (UNDO_LOG
tables) per participating service, then replays them in reverse if any branch fails. It trades the
full flexibility of hand-written compensations for much less boilerplate - a reasonable choice when your
stack is already Java/Spring-heavy and the transaction graph isn't too deep.
Comparing the approaches
| Approach | Consistency model | Coordinator | Best fit |
|---|---|---|---|
| @Transactional (single DB) | Strong (ACID) | None needed | Monoliths, single-service writes |
| 2PC across services | Strong, but blocking | Transaction manager | Rare - low-throughput, few participants |
| Choreographed SAGA | Eventual, compensating | None - event-driven | Small step count, decoupled teams |
| Orchestrated SAGA | Eventual, compensating | Explicit orchestrator | Complex, auditable, multi-step flows |
| Seata @GlobalTransactional | Eventual, auto-compensating | Seata server | Java-heavy stacks wanting less boilerplate |
Key takeaways
@Transactionalonly protects writes within one database connection - it does not extend across service boundaries, no matter how it's annotated.- The SAGA pattern replaces atomic rollback with compensating transactions - write the "undo" for every step before you ship the "do".
- Choreography suits small, decoupled flows; orchestration suits complex flows you need to audit and reason about in one place.
- Seata's
@GlobalTransactionalis a legitimate shortcut for Java-heavy stacks, at the cost of an extra operational component and longer-held locks. - Every SAGA needs an idempotency strategy at each step, since compensations and retries can both fire more than once - see our notification idempotency piece for the pattern.
Zetrixweb