What is the Saga Pattern?
The Saga Pattern manages distributed transactions across multiple microservices. Instead of a single ACID transaction spanning multiple databases, a saga breaks the transaction into a sequence of local transactions. Each local transaction updates a single service and publishes an event or message to trigger the next step. If any step fails, compensating transactions undo the preceding steps.
Why Sagas?
- No distributed transactions: Two-phase commit (2PC) does not scale in microservices — sagas provide an alternative
- Eventual consistency: Sagas embrace eventual consistency rather than trying to achieve strong consistency across services
- Fault tolerance: Each step has a compensating action, making the system resilient to partial failures
- Autonomy: Services remain independent — they do not need to participate in distributed locking protocols
Orchestration-Based Saga
In an orchestration-based saga, a central saga orchestrator tells each participant what to do. The orchestrator maintains the state of the saga and decides the next step based on the outcome of each local transaction.
// Saga step definition
interface SagaStep {
name: string;
execute(context: TContext): Promise;
compensate(context: TContext): Promise;
}
// Saga orchestrator
class SagaOrchestrator {
private steps: SagaStep[] = [];
addStep(step: SagaStep): SagaOrchestrator {
this.steps.push(step);
return this;
}
async execute(initialContext: TContext): Promise {
let context = initialContext;
const executedSteps: SagaStep[] = [];
for (const step of this.steps) {
try {
console.log(`Executing saga step: ${step.name}`);
context = await step.execute(context);
executedSteps.push(step);
} catch (error) {
console.error(`Saga step ${step.name} failed:`, error);
// Compensate in reverse order
for (const executedStep of executedSteps.reverse()) {
try {
console.log(`Compensating step: ${executedStep.name}`);
context = await executedStep.compensate(context);
} catch (compensationError) {
console.error(
`Compensation failed for ${executedStep.name}:`,
compensationError
);
// Log to dead-letter queue for manual intervention
}
}
throw new SagaFailedError(step.name, error as Error);
}
}
return context;
}
}
// Order saga context
interface OrderSagaContext {
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
totalAmount: number;
reservationId?: string;
paymentId?: string;
shipmentId?: string;
}
// Concrete saga steps
const reserveInventoryStep: SagaStep = {
name: "ReserveInventory",
async execute(context) {
const result = await inventoryService.reserve(context.items);
return { ...context, reservationId: result.reservationId };
},
async compensate(context) {
if (context.reservationId) {
await inventoryService.release(context.reservationId);
}
return { ...context, reservationId: undefined };
},
};
const processPaymentStep: SagaStep = {
name: "ProcessPayment",
async execute(context) {
const result = await paymentService.charge(
context.totalAmount,
context.customerId
);
return { ...context, paymentId: result.paymentId };
},
async compensate(context) {
if (context.paymentId) {
await paymentService.refund(context.paymentId);
}
return { ...context, paymentId: undefined };
},
};
const createShipmentStep: SagaStep = {
name: "CreateShipment",
async execute(context) {
const result = await shippingService.createShipment(
context.orderId,
context.items
);
return { ...context, shipmentId: result.shipmentId };
},
async compensate(context) {
if (context.shipmentId) {
await shippingService.cancelShipment(context.shipmentId);
}
return { ...context, shipmentId: undefined };
},
};
// Composing the saga
const orderSaga = new SagaOrchestrator()
.addStep(reserveInventoryStep)
.addStep(processPaymentStep)
.addStep(createShipmentStep);
// Execute
const result = await orderSaga.execute({
orderId: "ord-123",
customerId: "cust-456",
items: [{ productId: "prod-1", quantity: 2, price: 29.99 }],
totalAmount: 59.98,
});
Choreography-Based Saga
In a choreography-based saga, there is no central orchestrator. Each service listens for events and decides independently what to do next. Services publish events after completing their local transaction, and other services react to those events.
// Choreography: Each service handles its part
// Order Service
class OrderService {
async placeOrder(command: PlaceOrderCommand): Promise {
const order = Order.create(command);
await this.orderRepo.save(order);
await this.eventBus.publish({
eventType: "OrderCreated",
data: { orderId: order.id, items: order.items, customerId: order.customerId },
});
}
// Listen for downstream events
async onPaymentFailed(event: PaymentFailedEvent): Promise {
const order = await this.orderRepo.findById(event.data.orderId);
order.markFailed("Payment declined");
await this.orderRepo.save(order);
}
async onShipmentCreated(event: ShipmentCreatedEvent): Promise {
const order = await this.orderRepo.findById(event.data.orderId);
order.markShipped(event.data.trackingId);
await this.orderRepo.save(order);
}
}
// Inventory Service reacts to OrderCreated
class InventoryService {
async onOrderCreated(event: OrderCreatedEvent): Promise {
try {
const reservation = await this.reserve(event.data.items);
await this.eventBus.publish({
eventType: "InventoryReserved",
data: { orderId: event.data.orderId, reservationId: reservation.id },
});
} catch (error) {
await this.eventBus.publish({
eventType: "InventoryReservationFailed",
data: { orderId: event.data.orderId, reason: (error as Error).message },
});
}
}
// Compensate if payment fails
async onPaymentFailed(event: PaymentFailedEvent): Promise {
await this.release(event.data.reservationId);
}
}
// Payment Service reacts to InventoryReserved
class PaymentService {
async onInventoryReserved(event: InventoryReservedEvent): Promise {
try {
const payment = await this.charge(event.data.orderId);
await this.eventBus.publish({
eventType: "PaymentProcessed",
data: { orderId: event.data.orderId, paymentId: payment.id },
});
} catch (error) {
await this.eventBus.publish({
eventType: "PaymentFailed",
data: { orderId: event.data.orderId, reservationId: event.data.reservationId },
});
}
}
}
Orchestration vs Choreography
| Aspect | Orchestration | Choreography |
|---|---|---|
| Complexity | Logic centralized in orchestrator | Logic distributed across services |
| Coupling | Orchestrator knows all participants | Services only know about events |
| Visibility | Easy to see the full flow | Requires event tracing to understand flow |
| Adding steps | Modify orchestrator | Add new event listener |
| Best for | Complex workflows, many steps | Simple flows, few participants |
Saga State Machine
// Persistent saga with state machine
enum SagaStatus {
STARTED = "STARTED",
INVENTORY_RESERVED = "INVENTORY_RESERVED",
PAYMENT_PROCESSED = "PAYMENT_PROCESSED",
COMPLETED = "COMPLETED",
COMPENSATING = "COMPENSATING",
FAILED = "FAILED",
}
interface SagaState {
sagaId: string;
orderId: string;
status: SagaStatus;
context: OrderSagaContext;
createdAt: Date;
updatedAt: Date;
}
class PersistentOrderSaga {
constructor(
private readonly sagaRepo: SagaRepository,
private readonly services: SagaServices
) {}
async start(context: OrderSagaContext): Promise {
const saga: SagaState = {
sagaId: generateId(),
orderId: context.orderId,
status: SagaStatus.STARTED,
context,
createdAt: new Date(),
updatedAt: new Date(),
};
await this.sagaRepo.save(saga);
await this.advance(saga);
return saga.sagaId;
}
private async advance(saga: SagaState): Promise {
try {
switch (saga.status) {
case SagaStatus.STARTED: {
const reservation = await this.services.inventory.reserve(saga.context.items);
saga.context.reservationId = reservation.id;
saga.status = SagaStatus.INVENTORY_RESERVED;
await this.sagaRepo.save(saga);
await this.advance(saga);
break;
}
case SagaStatus.INVENTORY_RESERVED: {
const payment = await this.services.payment.charge(saga.context.totalAmount);
saga.context.paymentId = payment.id;
saga.status = SagaStatus.PAYMENT_PROCESSED;
await this.sagaRepo.save(saga);
await this.advance(saga);
break;
}
case SagaStatus.PAYMENT_PROCESSED: {
await this.services.shipping.createShipment(saga.orderId);
saga.status = SagaStatus.COMPLETED;
await this.sagaRepo.save(saga);
break;
}
}
} catch (error) {
saga.status = SagaStatus.COMPENSATING;
await this.sagaRepo.save(saga);
await this.compensate(saga);
}
}
private async compensate(saga: SagaState): Promise {
// Compensate in reverse order based on what was completed
if (saga.context.paymentId) {
await this.services.payment.refund(saga.context.paymentId);
}
if (saga.context.reservationId) {
await this.services.inventory.release(saga.context.reservationId);
}
saga.status = SagaStatus.FAILED;
await this.sagaRepo.save(saga);
}
}
Saga Best Practices
- Idempotent operations: Both execute and compensate steps must be idempotent — they may be retried
- Persist saga state: Store the saga state in a database so it survives process crashes
- Semantic locks: Use status flags (e.g., PENDING) to prevent concurrent modifications during saga execution
- Timeouts: Set timeouts for each step — if a service does not respond, trigger compensation
- Dead-letter handling: When compensation itself fails, route to a dead-letter queue for manual resolution