Transactions
⭐ Interview Importance: HIGH
⏱️ Revision Time: 12 min
Transactions ensure that a series of database operations either all succeed together or all fail together, preventing corrupted or incomplete data states.
Overview
Imagine a banking app where a user transfers $100 to a friend. The database must perform two operations:
- Deduct $100 from User A
- Add $100 to User B
If the server crashes exactly between step 1 and step 2, User A loses their money, and User B never gets it. A transaction wraps both operations. If step 2 fails, the database automatically “rolls back” step 1, as if it never happened.
Key Concepts
- ACID: Transactions guarantee Atomicity, Consistency, Isolation, and Durability.
- QueryRunner: In TypeORM, the
QueryRunnerprovides a single, isolated database connection used specifically to execute a transaction. - Commit / Rollback:
commitTransaction()permanently saves the changes.rollbackTransaction()undoes them.
Code Examples
Standard Transaction using QueryRunner (TypeORM)
This is the most robust and recommended way to handle transactions in NestJS with TypeORM.
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { User } from './user.entity';
import { Account } from './account.entity';
@Injectable()
export class BankingService {
constructor(
// Inject the root DataSource connection, not a specific Repository
private dataSource: DataSource,
) {}
async transferMoney(fromUserId: number, toUserId: number, amount: number) {
// 1. Create a new QueryRunner and connect it to the DB
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
// 2. Start the transaction!
await queryRunner.startTransaction();
try {
// 3. Execute operations USING THE QUERYRUNNER, not standard repositories!
// Deduct money
const fromAccount = await queryRunner.manager.findOneBy(Account, { userId: fromUserId });
fromAccount.balance -= amount;
await queryRunner.manager.save(fromAccount);
// Add money
const toAccount = await queryRunner.manager.findOneBy(Account, { userId: toUserId });
toAccount.balance += amount;
await queryRunner.manager.save(toAccount);
// 4. If we reach this line, both operations succeeded. Commit them!
await queryRunner.commitTransaction();
} catch (err) {
// 5. If an error occurs anywhere in the try block, undo everything!
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException('Transfer failed, money safely refunded');
} finally {
// 6. You MUST release the query runner, otherwise you will cause connection leaks!
await queryRunner.release();
}
}
}
Best Practices
- Never use standard repositories inside a transaction block: A common mistake is starting a transaction with
queryRunner.startTransaction(), but then usingthis.userRepository.save()inside the try block.this.userRepositoryuses the global connection pool, not the isolated transaction connection. You MUST usequeryRunner.manager.save()for the transaction to actually work. - Always use
finally: If you forgetqueryRunner.release()inside afinallyblock, and an error is thrown, the connection remains open forever. Eventually, your connection pool will run out, and your entire application will freeze.