Skip to main content
BlogBackend

Saga Pattern in NestJS: Distributed Transactions Without 2PC

How to coordinate multi-service transactions in NestJS using the saga pattern, orchestration vs choreography, compensating actions, and idempotent step design.

Matthews Wong

Software Engineer

July 202611 min read
Saga Pattern in NestJS: Distributed Transactions Without 2PC

Photo by RLJ Photography NYC on flickr

Key Takeaway

The saga pattern replaces a single cross-service database transaction with a sequence of local transactions, each paired with a compensating action for when a later step fails. In NestJS, the CQRS module's Saga decorator implements this as an event-reactive orchestrator, and idempotent compensation handlers are what make retried messages safe.

The moment a checkout flow touches three services, an orders service, a payments service, and an inventory service, a single database transaction stops being an option. Each service owns its own database, so there is no shared connection to wrap in a BEGIN and COMMIT. Two-phase commit promises atomicity across nodes, but it does so by holding locks across the network while every participant waits for a coordinator to say go, and that coordinator becoming unavailable mid-vote can leave every participant blocked indefinitely.

The saga pattern solves the same problem with a different trade-off. Instead of one atomic transaction, a saga is a sequence of local transactions, each one committed immediately in its own service, with a compensating action defined for every step in case a later step fails. This article walks through implementing sagas in NestJS with the official CQRS module, comparing orchestration and choreography, and the idempotency rules that keep compensations safe when messages get redelivered.

Why two-phase commit falls apart in microservices

Two-phase commit (2PC) works well inside a single database cluster because the coordinator and participants share fast, reliable connections and a common failure domain. Spread across independently deployed services with independent databases, three problems show up quickly.

  • Blocking: if the coordinator crashes after participants vote yes but before it sends the commit message, every participant holds its locks until the coordinator recovers, freezing unrelated requests that touch the same rows.
  • Coupling: every service in the transaction must support the same protocol and be reachable synchronously at the same moment, which defeats the point of deploying services independently.
  • Availability: 2PC trades availability for consistency in a way that does not match how most product teams want their checkout or signup flow to behave under partial outages.

If a workflow genuinely needs strict ACID guarantees across two records, ask first whether those records belong in the same service and the same database. Splitting them across services only to reassemble a distributed transaction usually signals the wrong service boundary, not a missing 2PC library.

Orchestration vs choreography

There are two ways to structure a saga's control flow. In choreography, each service listens for events published by other services and decides for itself what to do next, with no central coordinator. In orchestration, a single orchestrator component holds the sequence of steps and their compensations, issuing a command to each service in turn and reacting to the result. Both are valid; the right choice depends on how many services participate and how much visibility the team needs into the flow.

ConcernChoreographyOrchestration
Where the saga's logic livesSpread across every participating serviceCentralized in one orchestrator class
Debugging a failed runRequires tracing events across servicesOne place to inspect the current step and state
Best fitTwo or three participants, simple flowFour or more steps, or steps that branch

Implementing an orchestrated saga with NestJS CQRS

NestJS ships a CQRS module with a Saga decorator built for exactly this use case. A saga method receives an observable stream of every event dispatched through the event bus, filters it down with the ofType operator, and maps a matching event to a new command that the command bus dispatches automatically. This keeps the orchestration logic declarative: react to an event, emit the next command, or emit a compensating command when something upstream reports failure.

// order-saga.ts
import { ICommand, ofType, Saga } from '@nestjs/cqrs'
import { Injectable } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { OrderCreatedEvent } from './events/order-created.event'
import { ReserveInventoryCommand } from './commands/reserve-inventory.command'
import { InventoryReservationFailedEvent } from './events/inventory-reservation-failed.event'
import { CancelOrderCommand } from './commands/cancel-order.command'

@Injectable()
export class OrderSaga {
  @Saga()
  orderCreated = (events$: Observable<ICommand>): Observable<ICommand> => {
    return events$.pipe(
      ofType(OrderCreatedEvent),
      map((event) => new ReserveInventoryCommand(event.orderId, event.items)),
    )
  }

  // Compensating step: undo the order if a downstream step fails
  @Saga()
  inventoryReservationFailed = (events$: Observable<ICommand>): Observable<ICommand> => {
    return events$.pipe(
      ofType(InventoryReservationFailedEvent),
      map((event) => new CancelOrderCommand(event.orderId, event.reason)),
    )
  }
}

Each command handler in the chain performs one local transaction and either publishes a success event that the saga reacts to next, or a failure event that triggers the matching compensation. The orchestrator itself, the OrderSaga class, never talks to another service's database directly. It only knows the shape of events coming in and commands going out, which keeps it testable without spinning up every downstream service.

A saga is not a rollback. Once the inventory service has actually decremented stock and committed that change, there is no automatic undo. The compensating command must perform an equal and opposite local transaction, incrementing stock back, and that compensation can itself fail, so plan for retries and a dead-letter path from day one.

Designing compensating actions correctly

A compensating action is not just the reverse of the forward action; it has to reverse the business effect while accounting for anything that happened in between. Four rules keep compensations reliable in practice.

  1. Compensations must be semantically opposite, not just structurally opposite: cancelling a reservation is not the same as deleting the reservation record if downstream services already read its existence to make their own decisions.
  2. Compensations must be retriable, since the message that triggers a compensation can be redelivered after a network partition, a consumer restart, or an at-least-once delivery guarantee firing twice.
  3. Compensations must tolerate the forward step never having happened, because the failure that triggers compensation can arrive before the forward step's own success event does, depending on message ordering.
  4. Compensations must be recorded, not silently retried forever, so an operator can see which sagas are stuck in a partially compensated state and intervene manually if automated retries are exhausted.
// cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
  constructor(
    private readonly orders: OrderRepository,
    private readonly ledger: ProcessedStepLedger, // dedupe table keyed by (sagaId, step)
  ) {}

  async execute(command: CancelOrderCommand): Promise<void> {
    const { orderId, reason } = command

    // Idempotency check — a redelivered event must not double-compensate
    const alreadyHandled = await this.ledger.wasProcessed(orderId, 'cancel-order')
    if (alreadyHandled) return

    const order = await this.orders.findById(orderId)
    if (order.status === 'cancelled') return // already terminal, safe no-op

    order.cancel(reason)
    await this.orders.save(order)
    await this.ledger.markProcessed(orderId, 'cancel-order')
  }
}

Idempotency: the property that makes retries safe

Message brokers overwhelmingly favor at-least-once delivery over exactly-once, because exactly-once delivery across a network partition is not actually achievable without cooperation from the receiver. That means every command handler in a saga, forward step or compensation, will eventually be invoked twice for the same logical event. The fix is not deduplication at the broker; it is designing each handler so that applying it twice produces the same end state as applying it once.

  • Check the entity's current state before acting: if an order is already in the cancelled state, a second CancelOrderCommand should be a no-op, not a second cancellation side effect.
  • Track processed step identifiers in a small ledger table keyed by saga id and step name, and skip execution when a matching row already exists.
  • Make side effects like sending a confirmation email conditional on a first-time flag from the ledger check, not on the command simply arriving, since arrival does not imply novelty.

Idempotent handlers have a nice side benefit beyond safety: they make local development trivial, since replaying an event twice while debugging never corrupts test data.

When to reach for a saga at all

Sagas add real operational cost: more event types, a ledger table for idempotency, and a mental model that spans services instead of living in one function. They earn that cost when a business process genuinely spans service boundaries and needs eventual consistency with a defined recovery path, like order fulfillment touching payments, inventory, and shipping. They are the wrong tool when the data actually belongs in one service; the fix there is usually consolidating ownership, not adding a saga on top of a bad boundary.

Start with orchestration even for a two-step saga. It costs one small class up front and pays for itself the first time a third step gets added, since choreography's implicit event chains get harder to extend than to write from scratch.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is the saga pattern in distributed systems?

The saga pattern coordinates a business transaction that spans multiple services by breaking it into a sequence of local transactions, each committed within its own service. If a later step fails, the saga runs compensating actions to undo the effects of the steps that already succeeded, instead of relying on a single atomic commit across services.

Why not just use two-phase commit for distributed transactions?

Two-phase commit requires every participant to hold locks while a coordinator collects votes, and if the coordinator fails mid-vote those locks can block indefinitely. It also forces every participating service to be reachable synchronously at the same moment, which conflicts with how independently deployed microservices are meant to operate.

How do I implement a saga in NestJS?

NestJS's CQRS module provides a Saga decorator that listens to an observable stream of events on the event bus, filters them with the ofType operator, and maps a matching event to a command that the command bus dispatches automatically. Each command handler performs one local transaction and emits either a success or failure event, which the same saga class reacts to for the next step or the compensating action.

What is the difference between orchestration and choreography in sagas?

Orchestration uses a single central component that holds the sequence of steps and issues commands to each service in turn, giving a single place to inspect saga state. Choreography has no central coordinator; each service reacts to events from other services independently, which works well for two or three participants but gets harder to trace as more steps are added.

Can compensating actions in a saga fail?

Yes, and this must be planned for. A compensating action is itself a local transaction that can fail due to network issues or business rule conflicts, so it needs the same retry and idempotency handling as the forward step, plus a dead-letter path and monitoring so an operator can intervene if automated retries are exhausted.