I’ve been thinking about a problem that keeps many developers awake at night. How do you handle a transaction that needs to touch multiple databases? Imagine a customer placing an order. You need to save the order in PostgreSQL, update inventory in MongoDB, and process a payment through a third-party service. If any step fails, everything must roll back cleanly. Traditional database transactions can’t help us here. They only work within a single database. This is where the Saga pattern comes in, and today, I’ll show you how to build one using NestJS and Temporal.
Think about the last time you bought something online. Did you ever wonder what happens behind the scenes when you click “Place Order”? The system doesn’t just save your order. It checks stock, reserves items, and charges your card. What if the payment fails after the inventory is already marked as reserved? Without a proper plan, you’d have a frustrated customer and incorrect stock levels. This is the exact problem we’re solving.
Let’s start by setting up our project. We’ll use NestJS as our framework, Temporal for orchestrating the workflow, and two databases: PostgreSQL for orders and MongoDB for inventory. First, create a new NestJS project and install the necessary packages.
nest new order-saga-system
cd order-saga-system
npm install @nestjs/typeorm typeorm pg mongoose @nestjs/mongoose
npm install @temporalio/worker @temporalio/client @temporalio/workflow
We need to run several services locally. Docker Compose makes this easy. Create a docker-compose.yml file to spin up PostgreSQL, MongoDB, and the Temporal server with its UI.
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: orders_db
ports:
- "5432:5432"
mongodb:
image: mongo:6
ports:
- "27017:27017"
temporal:
image: temporalio/auto-setup:latest
ports:
- "7233:7233"
temporal-ui:
image: temporalio/ui:latest
ports:
- "8080:8080"
Run docker-compose up -d to start everything. You can now access the Temporal Web UI at http://localhost:8080 to watch your workflows execute.
Now, let’s define our data. In PostgreSQL, we’ll have an Order entity. Notice the sagaId field. This will link our database record to the Temporal workflow instance, which is crucial for tracking.
// src/orders/entities/order.entity.ts
@Entity('orders')
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('decimal', { precision: 10, scale: 2 })
totalAmount: number;
@Column({
type: 'enum',
enum: OrderStatus,
default: OrderStatus.PENDING
})
status: OrderStatus;
@Column({ nullable: true })
sagaId: string; // Link to Temporal workflow
}
For inventory, we’ll use MongoDB with Mongoose. We need to track not just available stock, but also what’s been temporarily reserved for an ongoing order.
// src/inventory/schemas/inventory.schema.ts
@Schema()
export class Inventory {
@Prop()
productId: string;
@Prop()
availableStock: number;
@Prop()
reservedStock: number;
@Prop({ type: [Object] })
reservations: Array<{
reservationId: string;
quantity: number;
}>;
}
With our databases ready, we can define the heart of our system: the Saga workflow in Temporal. A Saga is a sequence of steps where each step has a compensating action. If step two fails, we execute the compensation for step one to undo it.
// src/workflows/order-saga.workflow.ts
export async function orderSagaWorkflow(input: OrderSagaInput) {
let orderId: string | null = null;
let reservationIds: string[] = [];
try {
// Step 1: Create the order record
orderId = await createOrder(input);
// Step 2: Reserve the items in inventory
reservationIds = await reserveInventory({ orderId, items: input.items });
// Step 3: Process the payment
await processPayment({ orderId, amount: input.totalAmount });
// If we get here, everything succeeded
return { orderId, status: 'CONFIRMED' };
} catch (error) {
// Something failed, we must compensate
console.log('Saga failed, starting compensation');
// Compensate in reverse order
if (reservationIds.length > 0) {
await releaseInventory({ reservationIds });
}
if (orderId) {
await cancelOrder({ orderId });
}
return { orderId, status: 'FAILED', failureReason: error.message };
}
}
See how the compensation flows backward? If the payment fails, we release the inventory and then cancel the order. This logic ensures our system stays consistent. But what are these createOrder and reserveInventory functions? In Temporal, they are called Activities. They contain the actual business logic that interacts with our databases and external services.
Let’s look at the createOrder activity. It’s a simple service method that saves an order and returns its ID. The key is that it’s wrapped by Temporal, which gives us automatic retries, timeouts, and observability.
// src/activities/order.activities.ts
@Injectable()
export class OrderActivities {
constructor(
@InjectRepository(Order)
private orderRepository: Repository<Order>
) {}
@Activity()
async createOrder(input: CreateOrderInput): Promise<string> {
const order = this.orderRepository.create({
customerId: input.customerId,
items: input.items,
totalAmount: input.totalAmount,
status: OrderStatus.PENDING,
sagaId: Context.info().workflowExecution.workflowId // Link to workflow
});
await this.orderRepository.save(order);
return order.id;
}
}
The inventory activity is more interesting. It needs to reserve stock atomically. We use a reservation ID so we can later identify and release this specific hold if the saga fails.
// src/activities/inventory.activities.ts
@Activity()
async reserveInventory(input: ReserveInventoryInput): Promise<string[]> {
const reservationIds: string[] = [];
for (const item of input.items) {
const reservationId = randomUUID();
const result = await this.inventoryModel.findOneAndUpdate(
{ productId: item.productId, availableStock: { $gte: item.quantity } },
{
$inc: { availableStock: -item.quantity, reservedStock: item.quantity },
$push: {
reservations: {
reservationId,
quantity: item.quantity,
orderId: input.orderId
}
}
}
);
if (!result) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
reservationIds.push(reservationId);
}
return reservationIds;
}
What happens if our workflow runs for a very long time? Temporal is built for this. Workflows are deterministic and can run for days or even months. They survive server restarts. This is perfect for operations like order fulfillment that might involve waiting for shipping confirmation.
To start a workflow from our NestJS controller, we use the Temporal client. This is how we connect our HTTP API to the background orchestration engine.
// src/orders/orders.controller.ts
@Post()
async createOrder(@Body() createOrderDto: CreateOrderDto) {
const handle = await this.temporalClient.workflow.start(orderSagaWorkflow, {
taskQueue: 'order-saga-queue',
workflowId: `order-${randomUUID()}`,
args: [{
customerId: createOrderDto.customerId,
items: createOrderDto.items,
totalAmount: createOrderDto.totalAmount,
}],
});
return { workflowId: handle.workflowId };
}
The controller immediately returns a workflow ID. The client can use this ID to query the status later. The actual work happens asynchronously in the Temporal worker. This keeps our API responsive.
Testing is critical for distributed systems. How do you test a saga that involves multiple databases and services? We can write integration tests that run the entire workflow. Temporal provides a test framework that mocks the activity executions, allowing us to simulate failures.
// test/order-saga.workflow.spec.ts
it('should compensate if payment fails', async () => {
const env = await TestWorkflowEnvironment.createLocal();
const mockActivities: Partial<typeof activities> = {
createOrder: async () => 'order-123',
reserveInventory: async () => ['res-456'],
processPayment: async () => { throw new Error('Payment declined'); },
cancelOrder: async () => {}, // Mock should be called
releaseInventory: async () => {}, // Mock should be called
};
const result = await env.workflow.execute(orderSagaWorkflow, {
args: [sampleInput],
workflowId: 'test-workflow',
});
expect(result.status).toBe('FAILED');
expect(mockActivities.cancelOrder).toHaveBeenCalled();
});
This test proves our compensation logic works. The payment activity throws an error, and the workflow correctly calls the cancelOrder and releaseInventory compensations.
Monitoring is the final piece. With Temporal’s UI, you can see every workflow execution, its history, and where it failed. You can also set up alerts. For example, if too many sagas are failing in the compensation phase, you might have a systemic issue with your payment service.
Building this changes how you think about application design. You move from trying to make everything happen instantly within a single transaction to designing a resilient sequence of events that can handle failure at any point. Your system becomes more robust and easier to reason about.
Have you considered how this pattern could apply to other processes, like user registration or data migration? The principles are the same: define the steps, define the rollback actions, and let a reliable orchestrator manage the execution.
I hope this guide helps you build more resilient systems. Distributed transactions don’t have to be scary. With the right pattern and tools, you can handle complexity with confidence. If you found this useful, please share it with a colleague who might be struggling with similar architectural challenges. I’d love to hear about your experiences in the comments below. What’s the most complex saga you’ve had to design?
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva