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.
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);
}
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!
}
}
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, ...);
}
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
-
Automatic Context Propagation : The active
QueryRunneris stored globally for the current request. Repositories can retrieve it automatically. -
Intelligent Nesting : If you call
executeInTransactionwithin an existing transaction, the library automatically creates a SAVEPOINT instead of opening a new connection. This prevents deadlocks and connection pool exhaustion. - 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()...
}
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;
});
}
}
Installation & Setup
Install the library via npm:
npm install nest-als-transaction
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 {}
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);
});
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)