nest-als-transaction
v1.0.3
Published
NestJS Transaction Helper using AsyncLocalStorage
Readme
NestJS ALS Transaction Helper
Note: This library is designed specifically for TypeORM and manages
QueryRunnercontexts.
A NestJS library for managing database transactions transparently using Node.js AsyncLocalStorage. This allows you to perform nested transactions and manage QueryRunner contexts without passing them as arguments throughout your service methods.
The Challenge
In complex NestJS applications, managing database transactions across multiple service layers often leads to three major problems:
1. Data Inconsistency (No Transaction)
By default, TypeORM operations run in their own auto-commit transactions. If a business process involves multiple steps and the last step fails, the previous steps are not rolled back, leaving the database in an inconsistent state.
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 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
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
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
// 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.
Key Benefits
- Automatic Context Propagation: The active
QueryRunneris stored globally for the request. You don't need to pass it down the stack. - Intelligent Nesting: If you call
executeInTransactionwithin an existing transaction, it automatically creates a SAVEPOINT instead of opening a new connection. This prevents deadlocks and connection pool exhaustion. - 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.
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);
}
async update(criteria: any, partialEntity: DeepPartial<T>): Promise<UpdateResult> {
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...
}2. Usage in Services
Now your services can focus purely on business logic.
// 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
npm install nest-als-transactionSetup
Import TransactionModule in your AppModule. Note that this module is @Global().
Requirement: You must have TypeOrmModule configured and 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.
Option B: Manual Access
If you don't want to modify your Base Repository, you can still access the QueryRunner manually inside the transaction block:
this.txHelper.executeInTransaction(async (qr) => {
// Pass 'qr' manually to legacy code that requires it
await this.legacyService.doSomething(qr);
});License
MIT
Feel free to comment or clone this repo. If you find any issues, please let me know!
