Introduction
Database transactions are a crucial aspect of building reliable applications. In NestJS with TypeORM, managing transactions properly can make the difference between a robust application and one that suffers from data inconsistency issues.
In this comprehensive guide, we'll explore different approaches to handling transactions in NestJS, from basic implementations to advanced patterns that ensure data integrity in complex business operations.
Understanding Database Transactions
Before diving into NestJS-specific implementations, let's quickly review what database transactions are and why they matter:
- Atomicity: All operations within a transaction succeed or fail together
- Consistency: Database remains in a valid state before and after the transaction
- Isolation: Concurrent transactions don't interfere with each other
- Durability: Committed transactions persist even in case of system failure
Basic Transaction Implementation
TypeORM provides several ways to handle transactions. The most straightforward approach uses the @Transaction()
decorator:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Transaction, TransactionRepository } from 'typeorm';
import { User } from './user.entity';
import { Account } from './account.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Account)
private accountRepository: Repository<Account>,
) {}
@Transaction()
async createUserWithAccount(
userData: CreateUserDto,
@TransactionRepository(User) userRepo?: Repository<User>,
@TransactionRepository(Account) accountRepo?: Repository<Account>,
): Promise<User> {
// Create user
const user = userRepo.create(userData);
const savedUser = await userRepo.save(user);
// Create associated account
const account = accountRepo.create({
userId: savedUser.id,
balance: 0,
});
await accountRepo.save(account);
return savedUser;
}
}
Manual Transaction Management
For more control over transaction lifecycle, you can manage transactions manually using the EntityManager:
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
@Injectable()
export class TransferService {
constructor(
@InjectEntityManager()
private entityManager: EntityManager,
) {}
async transferMoney(fromUserId: number, toUserId: number, amount: number): Promise<void> {
await this.entityManager.transaction(async (manager) => {
// Debit from source account
await manager.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[amount, fromUserId]
);
// Credit to destination account
await manager.query(
'UPDATE accounts SET balance = balance + $1 WHERE user_id = $2',
[amount, toUserId]
);
// Log the transaction
await manager.save('transaction_log', {
fromUserId,
toUserId,
amount,
timestamp: new Date(),
});
});
}
}
Advanced Pattern: Transaction Interceptor
For applications with complex transaction requirements, you might want to implement a custom transaction interceptor:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError, concatMap } from 'rxjs/operators';
import { EntityManager } from 'typeorm';
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private readonly entityManager: EntityManager) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return new Observable((observer) => {
this.entityManager.transaction(async (manager) => {
// Inject transaction manager into request context
const request = context.switchToHttp().getRequest();
request.transactionManager = manager;
return next.handle().pipe(
concatMap(async (data) => {
observer.next(data);
observer.complete();
return data;
}),
catchError(async (error) => {
observer.error(error);
throw error;
}),
).toPromise();
});
});
}
}
Best Practices and Common Pitfalls
Here are some important considerations when working with transactions in NestJS:
1. Keep Transactions Short
Long-running transactions can cause performance issues and deadlocks. Keep your transaction scope as small as possible:
// ❌ Bad: Long-running transaction
@Transaction()
async processOrder(orderId: number) {
const order = await this.getOrder(orderId);
await this.validateInventory(order); // Might take time
await this.processPayment(order); // External API call
await this.updateInventory(order);
await this.sendConfirmationEmail(order); // External service
}
// ✅ Good: Minimal transaction scope
async processOrder(orderId: number) {
const order = await this.getOrder(orderId);
await this.validateInventory(order);
const paymentResult = await this.processPayment(order);
// Only database operations in transaction
await this.entityManager.transaction(async (manager) => {
await manager.save(order);
await manager.save(paymentResult);
await this.updateInventory(order, manager);
});
// Non-critical operations outside transaction
await this.sendConfirmationEmail(order);
}
2. Handle Deadlocks Gracefully
Implement retry logic for deadlock scenarios:
async transferWithRetry(fromUserId: number, toUserId: number, amount: number, retries = 3): Promise<void> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await this.transferMoney(fromUserId, toUserId, amount);
return; // Success, exit retry loop
} catch (error) {
if (error.code === 'DEADLOCK_DETECTED' && attempt < retries) {
// Wait with exponential backoff
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
continue;
}
throw error; // Re-throw if not deadlock or max retries reached
}
}
}
3. Use Proper Isolation Levels
Choose the appropriate isolation level for your use case:
await this.entityManager.transaction(
'READ COMMITTED', // or 'SERIALIZABLE', 'REPEATABLE READ', 'READ UNCOMMITTED'
async (manager) => {
// Transaction logic here
}
);
Testing Transactions
Testing transactional code requires special consideration. Here's a pattern for testing transaction rollback:
describe('UserService', () => {
let service: UserService;
let entityManager: EntityManager;
beforeEach(async () => {
const module = await Test.createTestingModule({
// Module setup
}).compile();
service = module.get<UserService>(UserService);
entityManager = module.get<EntityManager>(EntityManager);
});
it('should rollback transaction on error', async () => {
const initialUserCount = await entityManager.count(User);
// Mock a service method to throw an error mid-transaction
jest.spyOn(service, 'createAccount').mockRejectedValue(new Error('Account creation failed'));
await expect(service.createUserWithAccount(userData)).rejects.toThrow();
// Verify rollback - user count should remain unchanged
const finalUserCount = await entityManager.count(User);
expect(finalUserCount).toBe(initialUserCount);
});
});
Conclusion
Proper transaction management is essential for building reliable NestJS applications. Whether you use decorators, manual management, or custom interceptors, the key is to:
- Keep transactions as short as possible
- Handle errors and deadlocks gracefully
- Choose appropriate isolation levels
- Test your transactional logic thoroughly
By following these patterns and best practices, you'll build more robust applications that maintain data integrity even under complex business scenarios.
Happy coding!
Jed