The Saga Pattern for Distributed Transactions
How to implement the saga pattern for distributed transactions across microservices: orchestration versus choreography, in TypeScript, with compensation.

Distributed transactions are the hardest problem in microservices. When a single business operation spans multiple services — placing an order requires updating inventory, charging payment, and scheduling shipping — you cannot use a traditional database transaction. Each service has its own database. There is no shared ACID boundary.
The saga pattern solves this by breaking the distributed transaction into a sequence of local transactions. Each service executes its local transaction and publishes an event. If any step fails, previously completed steps are undone through compensating transactions — not rollbacks, but new transactions that reverse the effect.
Why Not Distributed Transactions
Two-phase commit (2PC) is the traditional approach to distributed transactions. It works, but it has fundamental problems in microservices architectures.
// ❌ Two-phase commit — reasons it fails in microservices
const twoPhaseCommitProblems = {
availability: 'If ANY participant is down, the entire transaction blocks',
latency: 'Coordinator must wait for ALL participants to respond',
coupling: 'All services must implement the 2PC protocol',
scalability: 'Locks are held across services during the prepare phase',
singlePointOfFailure: 'The coordinator failing leaves transactions in limbo',
};
// ✅ Saga pattern — eventual consistency without distributed locks
const sagaBenefits = {
availability: 'Each service operates independently',
latency: 'No waiting for distributed locks',
coupling: 'Services communicate via events, not protocols',
scalability: 'No cross-service locks',
resilience: 'Compensations handle partial failures gracefully',
};Saga Structure
A saga defines a sequence of steps, where each step has a forward action and a compensating action. If step N fails, compensations run for steps N-1 through 1 in reverse order.
// Core saga types
interface SagaStep<TContext> {
name: string;
execute: (context: TContext) => Promise<TContext>;
compensate: (context: TContext) => Promise<TContext>;
}
interface SagaResult<TContext> {
success: boolean;
context: TContext;
completedSteps: string[];
failedStep?: string;
error?: string;
}
// Saga executor — orchestration style
async function executeSaga<TContext>(
steps: SagaStep<TContext>[],
initialContext: TContext
): Promise<SagaResult<TContext>> {
let context = { ...initialContext };
const completedSteps: string[] = [];
for (const step of steps) {
try {
console.log(`Executing step: ${step.name}`);
context = await step.execute(context);
completedSteps.push(step.name);
} catch (error) {
console.error(`Step '${step.name}' failed:`, error);
// Run compensations in reverse order
for (const completedStep of [...completedSteps].reverse()) {
const stepDef = steps.find((s) => s.name === completedStep);
if (stepDef) {
try {
console.log(`Compensating step: ${completedStep}`);
context = await stepDef.compensate(context);
} catch (compError) {
console.error(
`Compensation for '${completedStep}' failed:`,
compError
);
// Log for manual intervention — compensation failures
// are the hardest problem in sagas
}
}
}
return {
success: false,
context,
completedSteps,
failedStep: step.name,
error: String(error),
};
}
}
return { success: true, context, completedSteps };
}Order Saga Example
An order placement saga coordinates inventory reservation, payment charging, and shipping scheduling. Each step has a clear compensation.
interface OrderSagaContext {
orderId: string;
customerId: string;
items: { productId: string; quantity: number; price: number }[];
total: number;
reservationId?: string;
paymentId?: string;
shipmentId?: string;
}
const orderSagaSteps: SagaStep<OrderSagaContext>[] = [
{
name: 'reserve-inventory',
execute: async (ctx) => {
const reservation = await inventoryService.reserve(
ctx.items.map((i) => ({
productId: i.productId,
quantity: i.quantity,
}))
);
return { ...ctx, reservationId: reservation.id };
},
compensate: async (ctx) => {
if (ctx.reservationId) {
await inventoryService.releaseReservation(ctx.reservationId);
}
return { ...ctx, reservationId: undefined };
},
},
{
name: 'charge-payment',
execute: async (ctx) => {
const payment = await paymentService.charge({
customerId: ctx.customerId,
amount: ctx.total,
orderId: ctx.orderId,
});
return { ...ctx, paymentId: payment.id };
},
compensate: async (ctx) => {
if (ctx.paymentId) {
await paymentService.refund(ctx.paymentId, ctx.total);
}
return { ...ctx, paymentId: undefined };
},
},
{
name: 'schedule-shipping',
execute: async (ctx) => {
const shipment = await shippingService.schedule({
orderId: ctx.orderId,
items: ctx.items,
});
return { ...ctx, shipmentId: shipment.id };
},
compensate: async (ctx) => {
if (ctx.shipmentId) {
await shippingService.cancel(ctx.shipmentId);
}
return { ...ctx, shipmentId: undefined };
},
},
];
// Execute the saga
async function placeOrder(order: OrderSagaContext): Promise<void> {
const result = await executeSaga(orderSagaSteps, order);
if (result.success) {
await orderDb.updateStatus(order.orderId, 'confirmed');
console.log(`Order ${order.orderId} placed successfully`);
} else {
await orderDb.updateStatus(order.orderId, 'failed');
console.error(
`Order ${order.orderId} failed at step '${result.failedStep}'`
);
}
}Orchestration vs Choreography
There are two approaches to coordinating saga steps. Orchestration uses a central coordinator. Choreography uses events — each service publishes an event after completing its step, and the next service reacts to that event.
// ORCHESTRATION — central coordinator controls the flow
// Pros: Clear flow, easy to understand, centralized error handling
// Cons: Single point of failure, coordinator can become a bottleneck
class OrderSagaOrchestrator {
async execute(order: OrderSagaContext): Promise<SagaResult<OrderSagaContext>> {
// Coordinator explicitly calls each service in order
// and handles compensations on failure
return executeSaga(orderSagaSteps, order);
}
}
// CHOREOGRAPHY — services react to events autonomously
// Pros: No central coordinator, services are fully decoupled
// Cons: Hard to track flow, distributed error handling, debugging is harder
// Order service publishes: order.created
// → Inventory service listens, reserves stock, publishes: inventory.reserved
// → Payment service listens, charges payment, publishes: payment.charged
// → Shipping service listens, schedules shipment, publishes: shipment.scheduled
// → Order service listens, marks order as confirmed// ❌ Choreography when flow is complex — becomes hard to follow
// Event chain: A → B → C → D → E → F
// If D fails, who triggers compensation for C, B, and A?
// Each service must know about compensations for upstream services
// Debugging: events are scattered across service logs
// ✅ Orchestration for complex flows — clear control flow
// Orchestrator: A, then B, then C, then D, then E, then F
// If D fails: compensate C, compensate B, compensate A
// All compensation logic is in one place
// Debugging: one service shows the entire saga lifecycleHandling Compensation Failures
The hardest part of sagas is when the compensation itself fails. If payment was charged and the refund fails, you have an inconsistent state that requires manual intervention.
interface CompensationRecord {
sagaId: string;
stepName: string;
status: 'pending' | 'completed' | 'failed';
attempts: number;
lastError?: string;
context: Record<string, unknown>;
}
async function compensateWithRetries(
step: SagaStep<OrderSagaContext>,
context: OrderSagaContext,
sagaId: string,
maxRetries: number = 3
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await step.compensate(context);
await db.query(
`UPDATE compensation_log
SET status = 'completed', attempts = $2
WHERE saga_id = $1 AND step_name = $3`,
[sagaId, attempt, step.name]
);
return;
} catch (error) {
lastError = error as Error;
console.error(
`Compensation retry ${attempt}/${maxRetries} for ${step.name}:`,
error
);
// Exponential backoff between retries
if (attempt < maxRetries) {
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
// All retries exhausted — log for manual intervention
await db.query(
`UPDATE compensation_log
SET status = 'failed', attempts = $2, last_error = $3
WHERE saga_id = $1 AND step_name = $4`,
[sagaId, maxRetries, lastError?.message, step.name]
);
// Alert operations team
await alerting.critical(
`Saga compensation failed after ${maxRetries} retries`,
{
sagaId,
step: step.name,
error: lastError?.message,
}
);
}Saga State Persistence
For production systems, saga state must be persisted so that incomplete sagas can be resumed after crashes.
// Store saga state in the database
interface SagaState {
id: string;
type: string;
status: 'running' | 'completed' | 'compensating' | 'failed';
currentStep: number;
context: Record<string, unknown>;
completedSteps: string[];
createdAt: Date;
updatedAt: Date;
}
async function persistSagaState(state: SagaState): Promise<void> {
await db.query(
`INSERT INTO sagas (id, type, status, current_step, context, completed_steps, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (id) DO UPDATE SET
status = $3,
current_step = $4,
context = $5,
completed_steps = $6,
updated_at = NOW()`,
[
state.id,
state.type,
state.status,
state.currentStep,
JSON.stringify(state.context),
JSON.stringify(state.completedSteps),
]
);
}
// Recovery: find and resume incomplete sagas after service restart
async function recoverIncompleteSagas(): Promise<void> {
const incomplete = await db.query(
`SELECT * FROM sagas
WHERE status IN ('running', 'compensating')
AND updated_at < NOW() - INTERVAL '5 minutes'`
);
for (const saga of incomplete.rows) {
console.log(`Recovering saga ${saga.id} (status: ${saga.status})`);
if (saga.status === 'compensating') {
await runCompensations(saga);
} else {
await resumeSagaFromStep(saga, saga.current_step);
}
}
}Key Takeaways
- Sagas replace distributed transactions — each service runs a local transaction and publishes an event; no distributed locks
- Every step needs a compensating action — if payment is charged, the compensation is a refund; if inventory is reserved, the compensation is a release
- Use orchestration for complex flows — a central coordinator makes the saga flow clear and compensation logic centralized
- Handle compensation failures explicitly — retry with backoff, then alert for manual intervention; this is the hardest edge case
- Persist saga state — if the orchestrator crashes, incomplete sagas must be recoverable from the database
- Compensations are not rollbacks — they are new transactions that reverse the effect; the intermediate states are visible to other services


