js

How to Handle Distributed Transactions with NestJS, Temporal, and the Saga Pattern

Learn to manage multi-database transactions using the Saga pattern with NestJS and Temporal for resilient, fault-tolerant systems.

How to Handle Distributed Transactions with NestJS, Temporal, and the Saga Pattern

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

Keywords: nestjs,temporal,saga pattern,distributed transactions,microservices



Similar Posts
Blog Image
Build Type-Safe APIs with tRPC, Prisma, and Next.js: Complete Developer Guide 2024

Learn to build type-safe APIs with tRPC, Prisma & Next.js. Complete guide covers setup, database design, advanced patterns & deployment strategies.

Blog Image
How to Build a Scalable Serverless GraphQL API with AWS AppSync

Learn how to create a powerful, serverless GraphQL API using AWS AppSync, DynamoDB, and Lambda—no server management required.

Blog Image
Building Real-Time Connected Apps with Feathers.js and Neo4j

Discover how combining Feathers.js and Neo4j creates fast, intelligent apps with real-time updates and native relationship modeling.

Blog Image
Complete Guide to Next.js Prisma ORM Integration: Build Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe database operations, API routes, and full-stack TypeScript applications. Build faster with modern tools.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Modern ORM

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build scalable database-driven apps with seamless data flow.

Blog Image
How to Build a Production-Ready Feature Flag System with Node.js and MongoDB

Learn how to build a scalable feature flag system using Node.js, MongoDB, and SSE for safer, real-time feature releases.