NestJS Transaction Helper using AsyncLocalStorage
npm install nest-als-transactionQueryRunner contexts.
AsyncLocalStorage. This allows you to perform nested transactions and manage QueryRunner contexts without passing them as arguments throughout your service methods.
typescript
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);
}
`
$3
To ensure data consistency, developers often manage transactions manually. This requires writing repetitive code to handle connections, start transactions, commit changes, and handle errors (rollback) in every single service method.
Example: Repetitive Transaction Code
`typescript
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
When multiple services work together (e.g., OrderService calls InventoryService), they must share the same transaction to be atomic. This forces you to pass the QueryRunner object as a parameter to every method in the chain.
Example: Burden of Passing Parameters
`typescript
// 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)
This library uses Node.js AsyncLocalStorage to store the transaction context implicitly during the request lifecycle.
$3
1. Automatic Context Propagation: The active QueryRunner is stored globally for the request. You don't need to pass it down the stack.
2. Intelligent Nesting: If you call executeInTransaction within an existing transaction, it automatically creates a SAVEPOINT instead of opening a new connection. This prevents deadlocks and connection pool exhaustion.
3. Clean Architecture: It enables "Zero Prop-Drilling" where your services and repositories don't just "know" about the transaction context without explicit parameters.
Integration Guide
To fully leverage this library and remove QueryRunner parameters from your code, integrate TransactionHelper into your Base Repository.
$3
Inject TransactionHelper and use it to retrieve the active QueryRunner.
`typescript
// 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 {
constructor(
private readonly baseRepo: Repository,
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): Promise {
return this.getManager().save(this.baseRepo.target, entity);
}
async update(criteria: any, partialEntity: DeepPartial): Promise {
const manager = this.getManager();
// Use QueryBuilder or manager.update depending on preference
return manager.getRepository(this.baseRepo.target).update(criteria, partialEntity);
}
// Implement other methods (delete, find, etc.) similarly...
}
`
$3
Now your services can focus purely on business logic.
`typescript
// src/services/order.service.ts
@Injectable()
export class OrderService {
constructor(
private readonly txHelper: TransactionHelper,
private readonly orderRepo: OrderRepository, // extends BaseRepository
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
`bash
npm install nest-als-transaction
`
Setup
Import TransactionModule in your AppModule. Note that this module is @Global().
Requirement: You must have TypeOrmModule configured and a DataSource available for injection.
`typescript
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
$3
Use the Base Repository integration shown above. This keeps your code cleanest.
$3
If you don't want to modify your Base Repository, you can still access the QueryRunner manually inside the transaction block:
`typescript
this.txHelper.executeInTransaction(async (qr) => {
// Pass 'qr' manually to legacy code that requires it
await this.legacyService.doSomething(qr);
});
``