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.

This is not a bug in Spring or your ORM - it's a structural fact about distributed systems. Two-phase commit (2PC) across services exists, but it locks resources across every participant for the duration of the transaction, which collapses throughput under real load. Most teams building at e-commerce or fintech scale use the SAGA pattern instead of distributed 2PC for exactly this reason.

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.

The trade-off: Seata's AT mode holds row-level locks longer than a pure choreographed SAGA, and adds an operational dependency (the Seata server) to your deployment. For 2-4 step flows in a single tech stack, it's often faster to ship. For long-running, cross-team, polyglot flows, a hand-written orchestrated SAGA usually ages better.

Comparing the approaches

ApproachConsistency modelCoordinatorBest fit
@Transactional (single DB)Strong (ACID)None neededMonoliths, single-service writes
2PC across servicesStrong, but blockingTransaction managerRare - low-throughput, few participants
Choreographed SAGAEventual, compensatingNone - event-drivenSmall step count, decoupled teams
Orchestrated SAGAEventual, compensatingExplicit orchestratorComplex, auditable, multi-step flows
Seata @GlobalTransactionalEventual, auto-compensatingSeata serverJava-heavy stacks wanting less boilerplate

Key takeaways

  • @Transactional only 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 @GlobalTransactional is 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.
Moving a monolith's checkout or payment flow into services and not sure where the transaction boundaries should sit? That design decision is much cheaper to get right on paper than to refactor after the first double-charge in production.