Saga Pattern in NestJS: Distributed Transactions Without 2PC

Photo by RLJ Photography NYC on flickr
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.
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.
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.
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.
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.

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.
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.
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.
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.
| Concern | Choreography | Orchestration |
|---|---|---|
| Where the saga's logic lives | Spread across every participating service | Centralized in one orchestrator class |
| Debugging a failed run | Requires tracing events across services | One place to inspect the current step and state |
| Best fit | Two or three participants, simple flow | Four or more steps, or steps that branch |
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.
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.
// 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')
}
}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.
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.
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.