Implementing Saga Pattern for Distributed Transactions
In a microservices architecture, ACID transactions cannot be used across service boundaries. The Saga Pattern solves the distributed consistency problem by breaking a business transaction into a sequence of local transactions in each service, with compensating transactions executed for rollback on failure.
Two Types of Saga
Choreography — services react to each other's events without a central coordinator:
OrderService InventoryService PaymentService ShippingService
│ │ │ │
│── OrderCreated ───►│ │ │
│ │── StockReserved ──►│ │
│ │ │── PaymentProcessed►│
│ │ │ │── ShipmentCreated
│ │ │ │
│ (on payment error) │ │
│ │◄── StockReleased ──│ │
Orchestration — a central Saga Orchestrator explicitly manages steps:
class CreateOrderSaga {
async execute(context: OrderSagaContext): Promise<void> {
try {
// Step 1: Reserve inventory
const reservation = await this.inventoryService.reserveStock(
context.orderId, context.items
);
context.reservationId = reservation.id;
// Step 2: Process payment
const payment = await this.paymentService.charge(
context.customerId, context.totalAmount
);
context.paymentId = payment.id;
// Step 3: Create shipment
await this.shippingService.createShipment(
context.orderId, context.shippingAddress
);
// Step 4: Confirm order
await this.orderService.confirmOrder(context.orderId);
} catch (error) {
await this.compensate(context, error);
throw new SagaFailedError(context.orderId, error);
}
}
async compensate(context: OrderSagaContext, failedAt: Error): Promise<void> {
// Compensations execute in reverse order
if (context.paymentId) {
await this.paymentService.refund(context.paymentId)
.catch(e => this.logger.error('Refund failed', e));
}
if (context.reservationId) {
await this.inventoryService.releaseReservation(context.reservationId)
.catch(e => this.logger.error('Release failed', e));
}
await this.orderService.cancelOrder(context.orderId, 'Saga compensation');
}
}
Persistent Saga with State
A saga must survive service restarts. State is stored in a database:
interface SagaState {
sagaId: string;
sagaType: string;
status: 'running' | 'completed' | 'failed' | 'compensating';
currentStep: number;
context: Record<string, unknown>;
completedSteps: string[];
failedStep?: string;
createdAt: Date;
updatedAt: Date;
}
class PersistentSagaOrchestrator {
async startSaga(sagaType: string, context: unknown): Promise<string> {
const sagaId = uuidv4();
await this.sagaRepo.save({
sagaId, sagaType, status: 'running',
currentStep: 0, context, completedSteps: []
});
await this.executeSaga(sagaId);
return sagaId;
}
async resumeSaga(sagaId: string): Promise<void> {
const state = await this.sagaRepo.findById(sagaId);
if (!state || state.status !== 'running') return;
// Resume from incomplete step
await this.executeSaga(sagaId, state.currentStep);
}
}
Temporal.io for Saga Orchestration
Temporal is a production-ready engine for long-running workflows (including sagas):
import { proxyActivities, sleep } from '@temporalio/workflow';
const { reserveStock, chargePayment, createShipment, releaseStock, refund } =
proxyActivities({ startToCloseTimeout: '10 seconds' });
export async function createOrderWorkflow(input: CreateOrderInput): Promise<void> {
let stockReserved = false;
let paymentCharged = false;
try {
await reserveStock({ orderId: input.orderId, items: input.items });
stockReserved = true;
await chargePayment({ orderId: input.orderId, amount: input.amount });
paymentCharged = true;
await createShipment({ orderId: input.orderId, address: input.address });
} catch (error) {
// Temporal guarantees compensation execution
if (paymentCharged) {
await refund({ orderId: input.orderId });
}
if (stockReserved) {
await releaseStock({ orderId: input.orderId });
}
throw error;
}
}
Temporal automatically retries activities, preserves execution history, and allows inspecting and debugging workflows through the UI.
Choreography via Kafka
// Order Service publishes event
await kafka.producer.send({
topic: 'order.events',
messages: [{ key: orderId, value: JSON.stringify({
type: 'OrderCreated', orderId, items, customerId
})}]
});
// Inventory Service listens and reserves
kafka.consumer.subscribe({ topic: 'order.events' });
kafka.consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.type !== 'OrderCreated') return;
try {
await inventoryService.reserveStock(event.orderId, event.items);
// Publish success
await kafka.producer.send({
topic: 'inventory.events',
messages: [{ key: event.orderId, value: JSON.stringify({
type: 'StockReserved', orderId: event.orderId
})}]
});
} catch {
// Publish failure — Order Service will rollback
await kafka.producer.send({
topic: 'inventory.events',
messages: [{ key: event.orderId, value: JSON.stringify({
type: 'StockReservationFailed', orderId: event.orderId
})}]
});
}
}
});
Idempotency — Mandatory Requirement
Every operation in a saga must be idempotent: repeated calls do not create duplicates.
async function reserveStock(orderId: string, items: Item[]): Promise<Reservation> {
// Check if reservation already exists for this order
const existing = await reservationRepo.findByOrderId(orderId);
if (existing) return existing; // idempotent
return reservationRepo.create({ orderId, items, status: 'reserved' });
}
Implementation Timeline
- Orchestration saga (2–3 services, without Temporal) — 1–2 weeks
- Saga with Temporal + state monitoring — 2–3 weeks
- Choreography via Kafka with idempotent handlers — 2–4 weeks







