NestJS — Enterprise Node.js

Transactions & Data Integrity

17 min Lesson 23 of 30

Transactions & Data Integrity

Some operations touch several rows or tables and must all succeed or all fail — never half. Transferring money debits one account and credits another; if the second write fails, the first must be undone. A transaction guarantees this all-or-nothing behaviour.

The classic example

Consider a bank transfer. Without a transaction, a crash between the two updates leaves money debited from one account but never credited to the other — corrupt data:

// DANGEROUS without a transaction await this.accounts.decrement({ id: from }, 'balance', amount); // if the process crashes HERE, money has vanished await this.accounts.increment({ id: to }, 'balance', amount);

ACID, briefly

Transactions provide ACID guarantees: Atomicity (all-or-nothing), Consistency (valid state to valid state), Isolation (concurrent transactions do not corrupt each other), and Durability (committed data survives crashes).

Transactions with the DataSource

The simplest approach in TypeORM is dataSource.transaction(). Everything inside the callback commits together, or rolls back entirely if anything throws:

import { DataSource } from 'typeorm'; @Injectable() export class TransferService { constructor(private dataSource: DataSource) {} async transfer(from: number, to: number, amount: number) { await this.dataSource.transaction(async (manager) => { await manager.decrement(Account, { id: from }, 'balance', amount); await manager.increment(Account, { id: to }, 'balance', amount); // throw anywhere here -> BOTH changes roll back automatically }); } }
Use the transaction's manager, not your repositories. Inside the callback, all reads and writes must go through the provided manager (or a query runner) so they share the same transaction. Calling a normal injected repository would run outside the transaction.

The QueryRunner approach

For finer control — explicit commit/rollback, or when you need to release the connection yourself — use a query runner:

const runner = this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(); try { await runner.manager.save(orderEntity); await runner.manager.save(paymentEntity); await runner.commitTransaction(); } catch (err) { await runner.rollbackTransaction(); throw err; } finally { await runner.release(); // always release the connection }
Always release the query runner. A query runner holds a database connection from the pool. Forgetting release() (use a finally block) leaks connections until the pool is exhausted and the app stops responding.

Keep transactions short

Do only database work inside a transaction. Never call a slow external API or send an email inside one — it holds locks the whole time, hurting concurrency. Do the external work before or after, and keep the transactional block focused on the writes that must be atomic.

Summary

Transactions make multi-step database changes atomic — all commit or all roll back — preserving data integrity (the ACID guarantees). Use dataSource.transaction() for the common case (operate through its manager), or a QueryRunner for explicit control (always release() in finally). Keep transactions short and free of external calls. Next: a different ORM, Prisma.