DEV Community

Harry Phung
Harry Phung

Posted on Originally published at blog.quangpv.top on

Efficient Transaction Management in NestJS with AsyncLocalStorage

Efficient Transaction Management in NestJS with AsyncLocalStorage

In complex NestJS applications using TypeORM, managing database transactions across multiple service layers often leads to significant challenges. The main struggle is ensuring data integrity without cluttering your codebase with repetitive transaction logic.

This post introduces a solution using AsyncLocalStorage (ALS) to transparently manage transaction contexts, effectively resolving common pitfalls.

Source Code : GitHub | Package : npm

The Challenge

Developers typically face three major problems when handling transactions:

1. Data Inconsistency (No Transaction)

By default, TypeORM operations run in their own auto-commit transactions. If a business process involves multiple steps (e.g., creating an order then deducting inventory) and the last step fails, the previous steps are not rolled back. This leaves the database in an inconsistent state, such as an order existing without the corresponding stock deduction.

Example

async createOrder(data) {
  // 1. Order created successfully
  const order = await this.orderRepo.save(data);

  // 2. Inventory deduction FAILS (e.g., db connection lost)
  // CRITICAL: The order persists, but stock was not deducted!
  await this.inventoryService.deductStock(data.items); 
}

Enter fullscreen mode Exit fullscreen mode

2. Complex Manual Management

To ensure data consistency, developers often resort to manual transaction management. This requires writing repetitive code to handle connections, start transactions, commit changes, and — crucially — handle errors with rollback mechanisms.

Example: Repetitive Transaction Code

async createOrder(data: CreateOrderDto) {
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const order = await queryRunner.manager.save(Order, data);
    // ... more operations
    await queryRunner.commitTransaction();
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err;
  } finally {
    await queryRunner.release(); // Easy to forget!
  }
}

Enter fullscreen mode Exit fullscreen mode

3. Service Integration Complexity (Prop-Drilling)

This is often the most intrusive issue. When multiple services need to work together within a single atomic operation (e.g., OrderService calls InventoryService), they must share the same transaction instance. This forces you to pass the QueryRunner object as a parameter to every method in the chain.

Example: Burden of Passing Parameters

// inventory.service.ts
// You must add 'queryRunner' to every method signature
async deductStock(items: Item[], queryRunner?: QueryRunner) {
    // You must check if a transaction exists
    const manager = queryRunner ? queryRunner.manager : this.defaultManager;

    // If you call another internal method, you must pass it again
    await this.updateLog(items, queryRunner);

    return manager.save(Inventory, ...);
}

Enter fullscreen mode Exit fullscreen mode

Repository Method Updates:

If you use a Base Repository, you must update every method (save, update, delete) to accept an optional QueryRunner, making your code verbose and harder to maintain.

The Solution: Async Local Storage (ALS)

The nest-als-transaction library leverages Node.js AsyncLocalStorage to store the transaction context implicitly during the request lifecycle. This is similar to ThreadLocal in Java, allowing context access anywhere in the execution chain without explicit parameter passing.

Key Benefits

  1. Automatic Context Propagation : The active QueryRunner is stored globally for the current request. Repositories can retrieve it automatically.
  2. Intelligent Nesting : If you call executeInTransaction within an existing transaction, the library automatically creates a SAVEPOINT instead of opening a new connection. This prevents deadlocks and connection pool exhaustion.
  3. Clean Architecture (Zero Prop-Drilling): Services and repositories do not need to know about the transaction context via arguments. They simply focus on business logic.

Integration Guide

To fully leverage this library and remove QueryRunner parameters from your code, you can integrate TransactionHelper directly into your Base Repository.

1. Setup Base Repository

Inject TransactionHelper and use it to retrieve the active QueryRunner.

// src/cores/base/base.repository.ts
import { Injectable } from '@nestjs/common';
import { Repository, DeepPartial, UpdateResult } from 'typeorm';
import { TransactionHelper } from 'nest-als-transaction';

@Injectable()
export class BaseRepository<T> {
  constructor(
    private readonly baseRepo: Repository<T>,
    private readonly txHelper: TransactionHelper
  ) {}

  /**
   * Retrieves the active EntityManager from ALS (if in a transaction)
   * or falls back to the default managers.
   */
  protected getManager() {
    const queryRunner = this.txHelper.getQueryRunner();
    return queryRunner ? queryRunner.manager : this.baseRepo.manager;
  }

  async save(entity: DeepPartial<T>): Promise<T> {
    return this.getManager().save(this.baseRepo.target, entity);
  }

  // Implement other methods utilizing getManager()...
}

Enter fullscreen mode Exit fullscreen mode

2. Usage in Services

Once the repository is set up, your services become clean and focused purely on business logic.

// src/services/order.service.ts
@Injectable()
export class OrderService {
  constructor(
    private readonly txHelper: TransactionHelper,
    private readonly orderRepo: OrderRepository,
    private readonly inventoryService: InventoryService
  ) {}

  async createOrder(data: CreateOrderDto) {
    // Start a transaction scope
    return this.txHelper.executeInTransaction(async () => {

      // 1. Create Order
      // The repo automatically picks up the transaction from ALS
      const order = await this.orderRepo.save(data);

      // 2. Call other service
      // No need to pass 'queryRunner' manually.
      // The InventoryService internally uses repositories that also pick up the context.
      await this.inventoryService.deductStock(data.items);

      return order;
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Installation & Setup

Install the library via npm:

npm install nest-als-transaction

Enter fullscreen mode Exit fullscreen mode

Import TransactionModule in your AppModule (it is global). Ensure you have TypeOrmModule configured with a DataSource available for injection.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransactionModule } from 'nest-als-transaction';

@Module({
  imports: [
    TypeOrmModule.forRoot({ ... }),
    TransactionModule,
  ],
})
export class AppModule {}

Enter fullscreen mode Exit fullscreen mode

Usage Options

Option A: Fully Integrated (Recommended)

Use the Base Repository integration shown above. This keeps your code cleanest by hiding the transaction logic inside the repository layer.

Option B: Manual Access

If you do not wish to modify your Base Repository, you can still access the QueryRunner manually inside the transaction block. This is useful for incremental adoption.

this.txHelper.executeInTransaction(async (qr) => {
    // Pass 'qr' manually to legacy code that requires it
    await this.legacyService.doSomething(qr);
});

Enter fullscreen mode Exit fullscreen mode

Conclusion

Using AsyncLocalStorage for transaction management in NestJS eliminates the noise of manual transaction handling. It provides a robust, clean, and scalable way to ensure data consistency across complex service interactions.

Top comments (0)