Back to Journal
System Design

Complete Guide to Saga Pattern Implementation with Typescript

A comprehensive guide to implementing Saga Pattern Implementation using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 16 min read

TypeScript's type system is uniquely suited for saga implementations — generic orchestrators enforce context shape across steps, discriminated unions model state transitions precisely, and async/await makes step sequencing readable. This guide builds a complete saga framework in TypeScript from the core abstractions through persistent storage, compensation, and observability.

Type Foundation

Start with the types that enforce correctness across the saga lifecycle.

typescript
1// types.ts
2export type FlowStatus = 'running' | 'compensating' | 'completed' | 'failed';
3 
4export interface SagaState<TContext = unknown> {
5 id: string;
6 sagaType: string;
7 status: FlowStatus;
8 currentStep: number;
9 context: TContext;
10 completedSteps: string[];
11 error?: string;
12 version: number;
13 createdAt: Date;
14 updatedAt: Date;
15}
16 
17export interface SagaStep<TContext> {
18 name: string;
19 execute: (ctx: TContext) => Promise<void>;
20 compensate: (ctx: TContext) => Promise<void>;
21 timeout?: number; // milliseconds
22 retryPolicy?: RetryPolicy;
23}
24 
25export interface RetryPolicy {
26 maxAttempts: number;
27 initialBackoffMs: number;
28 multiplier: number;
29 maxBackoffMs: number;
30 retryableErrors?: string[];
31}
32 
33export interface SagaStore {
34 save<T>(state: SagaState<T>): Promise<void>;
35 get<T>(id: string): Promise<SagaState<T> | null>;
36 saveWithOptimisticLock<T>(state: SagaState<T>): Promise<void>;
37}
38 
39export class SagaExecutionError extends Error {
40 constructor(
41 message: string,
42 public readonly sagaId: string,
43 public readonly failedStep: string,
44 public readonly cause?: Error
45 ) {
46 super(message);
47 this.name = 'SagaExecutionError';
48 }
49}
50 
51export class CompensationError extends Error {
52 constructor(
53 message: string,
54 public readonly sagaId: string,
55 public readonly failures: Array<{ step: string; error: Error }>
56 ) {
57 super(message);
58 this.name = 'CompensationError';
59 }
60}
61 

Saga Orchestrator

The generic orchestrator is typed to the saga context, ensuring every step's execute and compensate functions receive the correct context type.

typescript
1// orchestrator.ts
2import { v4 as uuid } from 'uuid';
3import type {
4 SagaState, SagaStep, SagaStore, RetryPolicy,
5} from './types';
6import { SagaExecutionError, CompensationError } from './types';
7 
8export class SagaOrchestrator<TContext> {
9 constructor(
10 private readonly steps: SagaStep<TContext>[],
11 private readonly store: SagaStore,
12 private readonly logger: Logger = console,
13 ) {}
14 
15 async execute(sagaType: string, context: TContext): Promise<string> {
16 const state: SagaState<TContext> = {
17 id: uuid(),
18 sagaType,
19 status: 'running',
20 currentStep: 0,
21 context,
22 completedSteps: [],
23 version: 1,
24 createdAt: new Date(),
25 updatedAt: new Date(),
26 };
27 
28 await this.store.save(state);
29 this.logger.info('Saga started', { sagaId: state.id, sagaType, steps: this.steps.length });
30 
31 await this.executeSteps(state);
32 return state.id;
33 }
34 
35 async resume(sagaId: string): Promise<void> {
36 const state = await this.store.get<TContext>(sagaId);
37 if (!state) throw new Error(`Saga ${sagaId} not found`);
38 if (state.status !== 'running') throw new Error(`Saga ${sagaId} is ${state.status}, cannot resume`);
39 
40 this.logger.info('Saga resumed', { sagaId, step: state.currentStep });
41 await this.executeSteps(state);
42 }
43 
44 private async executeSteps(state: SagaState<TContext>): Promise<void> {
45 for (let i = state.currentStep; i < this.steps.length; i++) {
46 const step = this.steps[i];
47 
48 this.logger.info('Executing step', { sagaId: state.id, step: step.name, index: i });
49 
50 try {
51 await this.executeWithRetry(step, state.context);
52 
53 state.currentStep = i + 1;
54 state.completedSteps.push(step.name);
55 state.updatedAt = new Date();
56 await this.store.saveWithOptimisticLock(state);
57 
58 } catch (error) {
59 this.logger.error('Step failed', { sagaId: state.id, step: step.name, error });
60 
61 state.status = 'compensating';
62 state.error = (error as Error).message;
63 state.updatedAt = new Date();
64 await this.store.saveWithOptimisticLock(state);
65 
66 await this.compensate(state);
67 
68 throw new SagaExecutionError(
69 `Saga failed at step ${step.name}`,
70 state.id,
71 step.name,
72 error as Error,
73 );
74 }
75 }
76 
77 state.status = 'completed';
78 state.updatedAt = new Date();
79 await this.store.saveWithOptimisticLock(state);
80 this.logger.info('Saga completed', { sagaId: state.id });
81 }
82 
83 private async executeWithRetry(step: SagaStep<TContext>, context: TContext): Promise<void> {
84 const policy: RetryPolicy = step.retryPolicy ?? {
85 maxAttempts: 1,
86 initialBackoffMs: 100,
87 multiplier: 2,
88 maxBackoffMs: 5000,
89 };
90 
91 let lastError: Error | null = null;
92 
93 for (let attempt = 0; attempt < policy.maxAttempts; attempt++) {
94 if (attempt > 0) {
95 const backoff = Math.min(
96 policy.initialBackoffMs * Math.pow(policy.multiplier, attempt - 1),
97 policy.maxBackoffMs,
98 );
99 await sleep(backoff);
100 }
101 
102 try {
103 if (step.timeout) {
104 await withTimeout(step.execute(context), step.timeout);
105 } else {
106 await step.execute(context);
107 }
108 return;
109 } catch (error) {
110 lastError = error as Error;
111 
112 if (policy.retryableErrors && !policy.retryableErrors.includes(lastError.name)) {
113 throw lastError;
114 }
115 
116 this.logger.warn('Step attempt failed', {
117 step: step.name,
118 attempt: attempt + 1,
119 maxAttempts: policy.maxAttempts,
120 error: lastError.message,
121 });
122 }
123 }
124 
125 throw lastError!;
126 }
127 
128 private async compensate(state: SagaState<TContext>): Promise<void> {
129 const failures: Array<{ step: string; error: Error }> = [];
130 
131 for (let i = state.completedSteps.length - 1; i >= 0; i--) {
132 const stepName = state.completedSteps[i];
133 const step = this.steps.find(s => s.name === stepName);
134 if (!step) continue;
135 
136 this.logger.info('Compensating step', { sagaId: state.id, step: stepName });
137 
138 try {
139 await withTimeout(step.compensate(state.context), 30000);
140 } catch (error) {
141 this.logger.error('Compensation failed', { sagaId: state.id, step: stepName, error });
142 failures.push({ step: stepName, error: error as Error });
143 }
144 }
145 
146 if (failures.length > 0) {
147 state.status = 'failed';
148 state.error += `; compensation failures: ${failures.map(f => f.step).join(', ')}`;
149 } else {
150 state.status = 'failed';
151 }
152 state.updatedAt = new Date();
153 await this.store.saveWithOptimisticLock(state);
154 
155 if (failures.length > 0) {
156 throw new CompensationError(
157 `Compensation failed for ${failures.length} steps`,
158 state.id,
159 failures,
160 );
161 }
162 }
163}
164 
165function sleep(ms: number): Promise<void> {
166 return new Promise(resolve => setTimeout(resolve, ms));
167}
168 
169function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
170 return Promise.race([
171 promise,
172 new Promise<never>((_, reject) =>
173 setTimeout(() => reject(new Error(`Step timed out after ${ms}ms`)), ms)
174 ),
175 ]);
176}
177 
178interface Logger {
179 info(message: string, meta?: Record<string, unknown>): void;
180 warn(message: string, meta?: Record<string, unknown>): void;
181 error(message: string, meta?: Record<string, unknown>): void;
182}
183 

PostgreSQL Store with Prisma

typescript
1// postgres-store.ts
2import { PrismaClient } from '@prisma/client';
3import type { SagaState, SagaStore } from './types';
4 
5export class PrismaSagaStore implements SagaStore {
6 constructor(private readonly prisma: PrismaClient) {}
7 
8 async save<T>(state: SagaState<T>): Promise<void> {
9 await this.prisma.sagaState.upsert({
10 where: { id: state.id },
11 create: {
12 id: state.id,
13 sagaType: state.sagaType,
14 status: state.status,
15 currentStep: state.currentStep,
16 context: state.context as any,
17 completedSteps: state.completedSteps,
18 error: state.error,
19 version: state.version,
20 createdAt: state.createdAt,
21 updatedAt: state.updatedAt,
22 },
23 update: {
24 status: state.status,
25 currentStep: state.currentStep,
26 context: state.context as any,
27 completedSteps: state.completedSteps,
28 error: state.error,
29 version: state.version,
30 updatedAt: state.updatedAt,
31 },
32 });
33 }
34 
35 async get<T>(id: string): Promise<SagaState<T> | null> {
36 const row = await this.prisma.sagaState.findUnique({ where: { id } });
37 if (!row) return null;
38 
39 return {
40 id: row.id,
41 sagaType: row.sagaType,
42 status: row.status as SagaState['status'],
43 currentStep: row.currentStep,
44 context: row.context as T,
45 completedSteps: row.completedSteps,
46 error: row.error ?? undefined,
47 version: row.version,
48 createdAt: row.createdAt,
49 updatedAt: row.updatedAt,
50 };
51 }
52 
53 async saveWithOptimisticLock<T>(state: SagaState<T>): Promise<void> {
54 const result = await this.prisma.sagaState.updateMany({
55 where: { id: state.id, version: state.version },
56 data: {
57 status: state.status,
58 currentStep: state.currentStep,
59 context: state.context as any,
60 completedSteps: state.completedSteps,
61 error: state.error,
62 version: state.version + 1,
63 updatedAt: new Date(),
64 },
65 });
66 
67 if (result.count === 0) {
68 throw new Error(`Optimistic lock conflict for saga ${state.id}`);
69 }
70 
71 state.version++;
72 }
73}
74 

Example: Order Fulfillment Saga

typescript
1// order-saga.ts
2import { SagaOrchestrator } from './orchestrator';
3import type { SagaStep, SagaStore } from './types';
4 
5interface OrderContext {
6 orderId: string;
7 customerId: string;
8 items: Array<{ productId: string; quantity: number; price: number }>;
9 totalAmount: number;
10 paymentMethodId: string;
11 shippingAddress: {
12 street: string;
13 city: string;
14 state: string;
15 zipCode: string;
16 country: string;
17 };
18 // Populated by steps
19 reservationId?: string;
20 chargeId?: string;
21 shipmentId?: string;
22 trackingNumber?: string;
23}
24 
25function createOrderFulfillmentSaga(
26 store: SagaStore,
27 inventoryService: InventoryService,
28 paymentService: PaymentService,
29 shippingService: ShippingService,
30 notificationService: NotificationService,
31): SagaOrchestrator<OrderContext> {
32 const steps: SagaStep<OrderContext>[] = [
33 {
34 name: 'reserve_inventory',
35 execute: async (ctx) => {
36 const result = await inventoryService.reserve({
37 orderId: ctx.orderId,
38 items: ctx.items,
39 idempotencyKey: `${ctx.orderId}-reserve`,
40 });
41 ctx.reservationId = result.reservationId;
42 },
43 compensate: async (ctx) => {
44 if (!ctx.reservationId) return;
45 await inventoryService.release({
46 reservationId: ctx.reservationId,
47 idempotencyKey: `${ctx.orderId}-release`,
48 });
49 },
50 timeout: 5000,
51 retryPolicy: {
52 maxAttempts: 3,
53 initialBackoffMs: 200,
54 multiplier: 2,
55 maxBackoffMs: 3000,
56 },
57 },
58 {
59 name: 'charge_payment',
60 execute: async (ctx) => {
61 const result = await paymentService.charge({
62 orderId: ctx.orderId,
63 amount: ctx.totalAmount,
64 paymentMethodId: ctx.paymentMethodId,
65 idempotencyKey: `${ctx.orderId}-charge`,
66 });
67 ctx.chargeId = result.chargeId;
68 },
69 compensate: async (ctx) => {
70 if (!ctx.chargeId) return;
71 await paymentService.refund({
72 chargeId: ctx.chargeId,
73 idempotencyKey: `${ctx.orderId}-refund`,
74 });
75 },
76 timeout: 30000,
77 retryPolicy: {
78 maxAttempts: 2,
79 initialBackoffMs: 1000,
80 multiplier: 2,
81 maxBackoffMs: 5000,
82 },
83 },
84 {
85 name: 'create_shipment',
86 execute: async (ctx) => {
87 const result = await shippingService.create({
88 orderId: ctx.orderId,
89 address: ctx.shippingAddress,
90 items: ctx.items,
91 idempotencyKey: `${ctx.orderId}-ship`,
92 });
93 ctx.shipmentId = result.shipmentId;
94 ctx.trackingNumber = result.trackingNumber;
95 },
96 compensate: async (ctx) => {
97 if (!ctx.shipmentId) return;
98 await shippingService.cancel(ctx.shipmentId);
99 },
100 timeout: 10000,
101 },
102 {
103 name: 'send_notification',
104 execute: async (ctx) => {
105 await notificationService.sendOrderConfirmation({
106 customerId: ctx.customerId,
107 orderId: ctx.orderId,
108 trackingNumber: ctx.trackingNumber!,
109 });
110 },
111 compensate: async () => {},
112 timeout: 5000,
113 },
114 ];
115 
116 return new SagaOrchestrator(steps, store);
117}
118 

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Express API Integration

typescript
1// routes/orders.ts
2import { Router } from 'express';
3import { z } from 'zod';
4 
5const CreateOrderSchema = z.object({
6 customerId: z.string().min(1),
7 items: z.array(z.object({
8 productId: z.string(),
9 quantity: z.number().int().positive(),
10 price: z.number().positive(),
11 })).min(1),
12 paymentMethodId: z.string().min(1),
13 shippingAddress: z.object({
14 street: z.string(),
15 city: z.string(),
16 state: z.string(),
17 zipCode: z.string(),
18 country: z.string(),
19 }),
20});
21 
22export function createOrderRoutes(saga: SagaOrchestrator<OrderContext>): Router {
23 const router = Router();
24 
25 router.post('/', async (req, res) => {
26 const parsed = CreateOrderSchema.safeParse(req.body);
27 if (!parsed.success) {
28 return res.status(400).json({ errors: parsed.error.issues });
29 }
30 
31 const { customerId, items, paymentMethodId, shippingAddress } = parsed.data;
32 const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
33 const orderId = crypto.randomUUID();
34 
35 const context: OrderContext = {
36 orderId,
37 customerId,
38 items,
39 totalAmount,
40 paymentMethodId,
41 shippingAddress,
42 };
43 
44 try {
45 const sagaId = await saga.execute('order-fulfillment', context);
46 
47 res.status(201).json({
48 sagaId,
49 orderId: context.orderId,
50 trackingNumber: context.trackingNumber,
51 status: 'completed',
52 });
53 } catch (error) {
54 res.status(500).json({
55 error: (error as Error).message,
56 orderId,
57 });
58 }
59 });
60 
61 return router;
62}
63 

In-Memory Store for Testing

typescript
1// test-store.ts
2import type { SagaState, SagaStore } from './types';
3 
4export class InMemorySagaStore implements SagaStore {
5 private states = new Map<string, SagaState<unknown>>();
6 
7 async save<T>(state: SagaState<T>): Promise<void> {
8 this.states.set(state.id, structuredClone(state) as SagaState<unknown>);
9 }
10 
11 async get<T>(id: string): Promise<SagaState<T> | null> {
12 const state = this.states.get(id);
13 return state ? structuredClone(state) as SagaState<T> : null;
14 }
15 
16 async saveWithOptimisticLock<T>(state: SagaState<T>): Promise<void> {
17 const existing = this.states.get(state.id);
18 if (existing && existing.version !== state.version) {
19 throw new Error(`Optimistic lock conflict for saga ${state.id}`);
20 }
21 state.version++;
22 this.states.set(state.id, structuredClone(state) as SagaState<unknown>);
23 }
24 
25 clear(): void {
26 this.states.clear();
27 }
28 
29 getAll(): SagaState<unknown>[] {
30 return Array.from(this.states.values());
31 }
32}
33 

Testing

typescript
1// orchestrator.test.ts
2import { describe, it, expect, vi } from 'vitest';
3import { SagaOrchestrator } from './orchestrator';
4import { InMemorySagaStore } from './test-store';
5import type { SagaStep } from './types';
6 
7interface TestContext {
8 value: string;
9 step1Ran: boolean;
10 step2Ran: boolean;
11 comp1Ran: boolean;
12}
13 
14describe('SagaOrchestrator', () => {
15 it('executes all steps in order', async () => {
16 const store = new InMemorySagaStore();
17 const executionOrder: string[] = [];
18 
19 const steps: SagaStep<TestContext>[] = [
20 {
21 name: 'step1',
22 execute: async (ctx) => { executionOrder.push('step1'); ctx.step1Ran = true; },
23 compensate: async () => {},
24 },
25 {
26 name: 'step2',
27 execute: async (ctx) => { executionOrder.push('step2'); ctx.step2Ran = true; },
28 compensate: async () => {},
29 },
30 ];
31 
32 const orchestrator = new SagaOrchestrator(steps, store);
33 const ctx: TestContext = { value: 'test', step1Ran: false, step2Ran: false, comp1Ran: false };
34 
35 const sagaId = await orchestrator.execute('test', ctx);
36 
37 expect(ctx.step1Ran).toBe(true);
38 expect(ctx.step2Ran).toBe(true);
39 expect(executionOrder).toEqual(['step1', 'step2']);
40 
41 const state = await store.get(sagaId);
42 expect(state?.status).toBe('completed');
43 expect(state?.completedSteps).toEqual(['step1', 'step2']);
44 });
45 
46 it('compensates completed steps when a step fails', async () => {
47 const store = new InMemorySagaStore();
48 
49 const steps: SagaStep<TestContext>[] = [
50 {
51 name: 'step1',
52 execute: async (ctx) => { ctx.step1Ran = true; },
53 compensate: async (ctx) => { ctx.comp1Ran = true; },
54 },
55 {
56 name: 'step2',
57 execute: async () => { throw new Error('step2 failed'); },
58 compensate: async () => {},
59 },
60 ];
61 
62 const orchestrator = new SagaOrchestrator(steps, store);
63 const ctx: TestContext = { value: 'test', step1Ran: false, step2Ran: false, comp1Ran: false };
64 
65 await expect(orchestrator.execute('test', ctx)).rejects.toThrow('step2');
66 expect(ctx.step1Ran).toBe(true);
67 expect(ctx.comp1Ran).toBe(true);
68 
69 const states = store.getAll();
70 expect(states[0].status).toBe('failed');
71 });
72 
73 it('retries on transient failures', async () => {
74 const store = new InMemorySagaStore();
75 let attempts = 0;
76 
77 const steps: SagaStep<TestContext>[] = [
78 {
79 name: 'flaky_step',
80 execute: async (ctx) => {
81 attempts++;
82 if (attempts < 3) throw new Error('transient');
83 ctx.step1Ran = true;
84 },
85 compensate: async () => {},
86 retryPolicy: { maxAttempts: 3, initialBackoffMs: 10, multiplier: 1, maxBackoffMs: 10 },
87 },
88 ];
89 
90 const orchestrator = new SagaOrchestrator(steps, store);
91 const ctx: TestContext = { value: 'test', step1Ran: false, step2Ran: false, comp1Ran: false };
92 
93 await orchestrator.execute('test', ctx);
94 expect(ctx.step1Ran).toBe(true);
95 expect(attempts).toBe(3);
96 });
97});
98 

Dead Letter Queue Integration

typescript
1// dlq.ts
2interface DeadLetterEntry {
3 sagaId: string;
4 sagaType: string;
5 failedStep: string;
6 error: string;
7 context: unknown;
8 completedSteps: string[];
9 timestamp: Date;
10}
11 
12export class DeadLetterQueue {
13 constructor(private readonly store: DeadLetterStore) {}
14 
15 async enqueue(entry: DeadLetterEntry): Promise<void> {
16 await this.store.save(entry);
17 
18 // Emit metric for monitoring
19 metrics.increment('saga.dlq.enqueued', {
20 saga_type: entry.sagaType,
21 failed_step: entry.failedStep,
22 });
23 }
24 
25 async process(handler: (entry: DeadLetterEntry) => Promise<void>): Promise<number> {
26 const entries = await this.store.getUnprocessed(100);
27 let processed = 0;
28 
29 for (const entry of entries) {
30 try {
31 await handler(entry);
32 await this.store.markProcessed(entry.sagaId);
33 processed++;
34 } catch (error) {
35 await this.store.incrementRetryCount(entry.sagaId);
36 }
37 }
38 
39 return processed;
40 }
41}
42 

Conclusion

TypeScript's generic type system lets you build a saga orchestrator where the compiler verifies that every step's execute and compensate functions operate on the same context type. This eliminates an entire class of runtime errors — passing the wrong context to a step, forgetting to populate a field that a later step depends on, or mistyping a property name in the compensation logic.

The orchestrator implementation is straightforward because TypeScript handles the complexity that other languages push to runtime: the SagaStep<TContext> generic ensures type coherence, async/await makes the step sequencing linear and readable, and the Promise.race pattern for timeouts avoids callback-based timer management.

For production use, pair this orchestrator with the Prisma store for PostgreSQL persistence and the dead letter queue for compensation failures. The in-memory store makes unit testing fast and deterministic — no database setup, no cleanup, no flaky tests from network calls. Test the orchestrator logic thoroughly with the in-memory store, then run a smaller set of integration tests against PostgreSQL to verify the persistence layer.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026