NestJS Transactions Tutorial Using TypeORM
Some operations in a real application must succeed or fail as a single, indivisible unit. When a customer transfers money between two bank accounts, both the debit from one account and the credit to the other must happen together; if either step fails, neither should take effect, or the bank's books would no longer balance. This all-or-nothing guarantee is exactly what database transactions provide.
This lesson explains what transactions are, why they matter for data integrity, and walks through implementing them in NestJS using TypeORM's QueryRunner API, the standard, explicit approach for wrapping multiple related database operations into a single atomic unit.
NestJS Transactions TypeORM: Learning Objectives
- Understand what a database transaction is and the guarantee it provides.
- Recognize real-world scenarios where transactions are essential for data integrity.
- Implement a transaction in NestJS using TypeORM's QueryRunner API.
- Correctly commit a successful transaction and roll back a failed one.
- Understand the importance of releasing a QueryRunner after use.
NestJS Transactions TypeORM: Key Terms and Definitions
- Database transaction: A sequence of one or more database operations that are executed as a single, indivisible unit, either fully succeeding or fully failing together.
- ACID properties: A set of guarantees (Atomicity, Consistency, Isolation, Durability) that well-implemented database transactions provide.
- Atomicity: The guarantee that all operations within a transaction either complete successfully together or have no effect at all if any part fails.
- QueryRunner: A TypeORM class providing manual control over a database connection, used to explicitly start, commit, or roll back a transaction.
- Rollback: The act of undoing all changes made within a transaction, restoring the database to its state before the transaction began, typically triggered when an error occurs.
How NestJS Transactions TypeORM Works: Detailed Explanation
Consider a money transfer between two bank accounts, a classic example used to explain transactions precisely because the stakes of getting it wrong are so intuitive. Transferring money involves at least two separate database operations: decreasing the sender's balance and increasing the receiver's balance. If the first operation succeeds but the application then crashes, or a second query fails due to a database error, before the second operation completes, the sender's money would simply vanish, deducted from their account but never credited to the receiver's. This is precisely the kind of inconsistency a transaction is designed to prevent.
A transaction guarantees atomicity, one of the four ACID properties that reliable database systems provide, meaning a whole group of operations either completes entirely and successfully, or if any single operation within it fails, every change made so far within that same transaction is automatically rolled back, leaving the database exactly as it was before the transaction began. From the perspective of anyone else querying the database, a transaction's changes only become visible all at once, when the transaction successfully commits, never in a partially-applied state.
In NestJS with TypeORM, the standard, most explicit way to implement a transaction is using TypeORM's QueryRunner. This involves obtaining a QueryRunner instance from your DataSource (or Connection in older TypeORM versions), explicitly calling connect() to acquire a database connection, then startTransaction() to begin the transaction itself. From this point, any database operations performed using this specific queryRunner's manager (rather than your usual injected repositories) become part of that transaction. If every operation succeeds, you call commitTransaction() to permanently apply all changes together. If any operation throws an error, you catch it and call rollbackTransaction() instead, undoing everything performed so far within that transaction.
A critical, easily overlooked final step is calling queryRunner.release() inside a finally block, regardless of whether the transaction succeeded or failed. This releases the underlying database connection back to TypeORM's connection pool; forgetting this step can gradually exhaust your application's available database connections under load, since each unreleased QueryRunner effectively holds onto a connection indefinitely.
Interview-Friendly Explanation
A strong interview or viva answer for nestjs transactions typeorm must go beyond a one-line definition. Interviewers evaluating BCA, MCA, B.Tech, and self-taught backend developers reward answers that combine a precise definition, a clear working explanation, a real-world example, and a conclusion that highlights why it matters in production Node.js applications. Use the four-point framework below when answering under time pressure.
- Definition point: Database transaction: A sequence of one or more database operations that are executed as a single, indivisible unit, either fully succeeding or fully failing together.
- Working point: Recognize real-world scenarios where transactions are essential for data integrity.
- Example point: Banking and fintech applications rely on transactions for virtually every operation involving money movement, exactly mirroring the fund transfer example shown in this lesson.
- Conclusion point: Transactions exist specifically to protect against partial, inconsistent updates when multiple related database operations must succeed or fail together.
How to Answer This in a Technical Interview
- Give a two-to-three sentence definition using correct database and ORM terminology.
- Add one specific example drawn from a real backend scenario such as an e-commerce, fintech, or SaaS database schema.
- Mention any trade-offs versus alternative approaches (e.g. TypeORM vs Prisma, SQL vs NoSQL) since interviewers often probe this contrast.
- Close with one benefit, limitation, or production use case.
- Avoid vague answers like "it just connects to the database" — interviewers filter these out immediately.
Practical Scenario
Imagine you are designing the data layer for a production SaaS product, similar to systems used at companies like Razorpay, Freshworks, or Zomato. Every lesson in this module maps directly to a decision you will make while building that backend: how data is structured, how it's queried efficiently, how relationships between entities are modeled, and how the schema evolves safely over time as the product grows.
NestJS Transactions TypeORM: Architecture and Flow Diagram
Visualize the transaction lifecycle:
[queryRunner.connect()] --> [queryRunner.startTransaction()] --> [Perform multiple operations via queryRunner.manager] --> Success?
--> Yes: [queryRunner.commitTransaction()] --> Changes permanently applied
--> No (error thrown): [queryRunner.rollbackTransaction()] --> All changes undone
--> Finally: [queryRunner.release()] --> Connection returned to the pool
NestJS Transactions TypeORM: QueryRunner Method Reference Table
| QueryRunner Method | Purpose |
|---|---|
| connect() | Acquires a dedicated database connection from the pool |
| startTransaction() | Begins a new transaction on that connection |
| manager.save() / .delete() etc. | Performs an operation as part of the current transaction |
| commitTransaction() | Permanently applies all changes made within the transaction |
| rollbackTransaction() | Undoes all changes made within the transaction since it began |
| release() | Returns the connection back to the pool; must always be called |
NestJS Transactions TypeORM: NestJS Code Example
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Account } from './account.entity';
@Injectable()
export class TransferService {
constructor(private dataSource: DataSource) {}
async transferFunds(
fromAccountId: number,
toAccountId: number,
amount: number,
): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const fromAccount = await queryRunner.manager.findOne(Account, {
where: { id: fromAccountId },
});
const toAccount = await queryRunner.manager.findOne(Account, {
where: { id: toAccountId },
});
if (!fromAccount || !toAccount) {
throw new Error('One or both accounts not found');
}
if (fromAccount.balance < amount) {
throw new Error('Insufficient balance');
}
fromAccount.balance -= amount;
toAccount.balance += amount;
await queryRunner.manager.save(fromAccount);
await queryRunner.manager.save(toAccount);
await queryRunner.commitTransaction(); // both changes applied together
} catch (error) {
await queryRunner.rollbackTransaction(); // undo everything on any failure
throw error;
} finally {
await queryRunner.release(); // always release the connection
}
}
}
transferFunds implements exactly the money transfer scenario used to introduce transactions in this lesson. It obtains a QueryRunner, connects, and starts a transaction, then performs both the balance deduction and balance addition using queryRunner.manager (rather than a regular injected repository), ensuring both operations participate in the same transaction. If both accounts are found and the sender has sufficient balance, both updated accounts are saved and the transaction is committed, applying both changes together. If anything fails at any point, whether a missing account, insufficient balance, or a database error during save, the catch block rolls back the entire transaction, ensuring neither account's balance is left in a partially-updated, inconsistent state. The finally block guarantees the connection is always released back to the pool, regardless of success or failure.
Real-World NestJS Transactions TypeORM Industry Examples
- Banking and fintech applications rely on transactions for virtually every operation involving money movement, exactly mirroring the fund transfer example shown in this lesson.
- E-commerce checkout processes commonly wrap order creation, inventory deduction, and payment recording in a single transaction, ensuring a customer is never charged for an order that fails to properly reduce stock, or vice versa.
- Airline and event ticketing systems use transactions to guarantee that seat/ticket reservation and payment confirmation happen atomically, preventing the possibility of double-booking a single seat under concurrent requests.
- Multi-step user registration flows that create related records across multiple tables (a user record, a profile record, and an initial settings record) often use transactions to ensure a partially-created, broken user account is never left behind if one step fails.
NestJS Transactions TypeORM Interview Questions and Answers
Q1. What is a database transaction and what specific guarantee does it provide?
Short answer: A database transaction is a sequence of one or more operations executed as a single, indivisible unit, providing the guarantee of atomicity: either every operation within the transaction succeeds and is permanently applied together, or if any operation fails, all changes made so far within that same transaction are automatically undone, leaving the database exactly as it was before.
Detailed explanation: Consider a money transfer between two bank accounts, a classic example used to explain transactions precisely because the stakes of getting it wrong are so intuitive. Transferring money involves at least two separate database operations: decreasing the sender's balance and increasing the receiver's balance. If the first operation succeeds but the application then crashes, or a second query fails due to a database error, before the second operation completes, the sender's money would simply vanish, deducted from their account but never credited to the receiver's. This is precisely the kind of inconsistency a transaction is designed to prevent. A transaction guarantees atomicity, one of the four ACID properties that reliable database systems provide, meaning a whole group of operations either completes entirely and successfully, or if any single operation within it fails, every change made so far within that same transaction is automatically rolled back, leaving the database exactly as it was before the transaction began. From the perspective of anyone else querying the database, a transaction's changes only become visible all at once, when the transaction successfully commits, never in a partially-applied state. In NestJS with TypeORM, the standard, most explicit way to implement a transaction is using TypeORM's QueryRunner. This involves obtaining a QueryRunner instance from your DataSource (or Connection in older TypeORM versions), explicitly calling connect() to acquire a database connection, then startTransaction() to begin the transaction itself. From this point, any database operations performed using this specific queryRunner's manager (rather than your usual injected repositories) become part of that transaction. If every operation succeeds, you call commitTransaction() to permanently apply all changes together. If any operation throws an error, you catch it and call rollbackTransaction() instead, undoing everything performed so far within that transaction. A critical, easily overlooked final step is calling queryRunner.release() inside a finally block, regardless of whether the transaction succeeded or failed. This releases the underlying database connection back to TypeORM's connection pool; forgetting this step can gradually exhaust your application's available database connections under load, since each unreleased QueryRunner effectively holds onto a connection indefinitely.
Practical example: Banking and fintech applications rely on transactions for virtually every operation involving money movement, exactly mirroring the fund transfer example shown in this lesson.
Interview tip: Transactions guarantee atomicity: all operations succeed together, or none take effect at all.
Revision hook: Transactions exist specifically to protect against partial, inconsistent updates when multiple related database operations must succeed or fail together.
Q2. Walk through how you would implement a fund transfer between two accounts using a TypeORM transaction.
Short answer: You would obtain a QueryRunner from the DataSource, call connect() and startTransaction(), then perform the balance deduction on the sender's account and the balance addition on the receiver's account using the queryRunner's manager, wrapped in a try-catch block. On success, you call commitTransaction(); on any error, you call rollbackTransaction() instead, and in a finally block you always call release() to return the connection to the pool.
Detailed explanation: transferFunds implements exactly the money transfer scenario used to introduce transactions in this lesson. It obtains a QueryRunner, connects, and starts a transaction, then performs both the balance deduction and balance addition using queryRunner.manager (rather than a regular injected repository), ensuring both operations participate in the same transaction. If both accounts are found and the sender has sufficient balance, both updated accounts are saved and the transaction is committed, applying both changes together. If anything fails at any point, whether a missing account, insufficient balance, or a database error during save, the catch block rolls back the entire transaction, ensuring neither account's balance is left in a partially-updated, inconsistent state. The finally block guarantees the connection is always released back to the pool, regardless of success or failure.
Practical example: E-commerce checkout processes commonly wrap order creation, inventory deduction, and payment recording in a single transaction, ensuring a customer is never charged for an order that fails to properly reduce stock, or vice versa.
Interview tip: QueryRunner workflow: connect() → startTransaction() → perform operations via manager → commitTransaction() or rollbackTransaction() → always release() in finally.
Revision hook: TypeORM's QueryRunner gives explicit, fine-grained control over exactly when a transaction begins, commits, or rolls back.
Q3. Why is it important to always call queryRunner.release() in a finally block?
Short answer: release() returns the database connection used by that QueryRunner back to TypeORM's connection pool. Forgetting to call it, especially if an error occurs, would leave that connection permanently held by the QueryRunner, and repeated occurrences of this mistake could gradually exhaust the application's available database connections under load.
Detailed explanation: Database transactions guarantee atomicity: a group of related operations either all succeed together and are permanently applied, or if any single operation fails, every change made within that transaction is automatically undone. In NestJS, TypeORM's QueryRunner provides explicit, manual control over this process, requiring you to connect() and startTransaction() before performing operations through the queryRunner's manager, then either commitTransaction() on success or rollbackTransaction() within a catch block on failure. Reliably calling release() in a finally block, regardless of outcome, is essential to avoid leaking database connections over time. Classic real-world use cases like fund transfers, order processing with inventory management, and multi-table record creation all depend on this pattern to maintain data integrity under any failure scenario.
Practical example: Airline and event ticketing systems use transactions to guarantee that seat/ticket reservation and payment confirmation happen atomically, preventing the possibility of double-booking a single seat under concurrent requests.
Interview tip: Operations must use queryRunner.manager (not the regular injected repository) to actually participate in the transaction.
Revision hook: Every transactional operation must consistently use the same QueryRunner's manager, not a mix of it and regular repositories.
Q4. What happens to database changes made within a transaction if an error is thrown before commitTransaction() is called?
Short answer: As long as rollbackTransaction() is properly called in response to the error (typically within a catch block), every change made within that transaction since it began is completely undone, restoring the database to its exact state before the transaction started, ensuring no partial, inconsistent changes are left behind.
Detailed explanation: Consider a money transfer between two bank accounts, a classic example used to explain transactions precisely because the stakes of getting it wrong are so intuitive. Transferring money involves at least two separate database operations: decreasing the sender's balance and increasing the receiver's balance. If the first operation succeeds but the application then crashes, or a second query fails due to a database error, before the second operation completes, the sender's money would simply vanish, deducted from their account but never credited to the receiver's. This is precisely the kind of inconsistency a transaction is designed to prevent. A transaction guarantees atomicity, one of the four ACID properties that reliable database systems provide, meaning a whole group of operations either completes entirely and successfully, or if any single operation within it fails, every change made so far within that same transaction is automatically rolled back, leaving the database exactly as it was before the transaction began. From the perspective of anyone else querying the database, a transaction's changes only become visible all at once, when the transaction successfully commits, never in a partially-applied state. In NestJS with TypeORM, the standard, most explicit way to implement a transaction is using TypeORM's QueryRunner. This involves obtaining a QueryRunner instance from your DataSource (or Connection in older TypeORM versions), explicitly calling connect() to acquire a database connection, then startTransaction() to begin the transaction itself. From this point, any database operations performed using this specific queryRunner's manager (rather than your usual injected repositories) become part of that transaction. If every operation succeeds, you call commitTransaction() to permanently apply all changes together. If any operation throws an error, you catch it and call rollbackTransaction() instead, undoing everything performed so far within that transaction. A critical, easily overlooked final step is calling queryRunner.release() inside a finally block, regardless of whether the transaction succeeded or failed. This releases the underlying database connection back to TypeORM's connection pool; forgetting this step can gradually exhaust your application's available database connections under load, since each unreleased QueryRunner effectively holds onto a connection indefinitely.
Practical example: Multi-step user registration flows that create related records across multiple tables (a user record, a profile record, and an initial settings record) often use transactions to ensure a partially-created, broken user account is never left behind if one step fails.
Interview tip: Classic transaction use cases: fund transfers, order creation with inventory deduction, multi-table user registration.
Revision hook: Reliably releasing the QueryRunner's connection in a finally block is a small detail with serious consequences if overlooked.
NestJS Transactions TypeORM MCQs and Practice Questions
1. What ACID property does a database transaction primarily guarantee?
- Isolation only
- Atomicity: all operations succeed together or none do
- Durability of the database server itself
- Automatic performance optimization
Answer: B. Atomicity: all operations succeed together or none do
Explanation: Atomicity, one of the four ACID properties, is the specific guarantee that all operations within a transaction either complete successfully as a whole or have no lasting effect at all if any part fails.
Concept link: Transactions guarantee atomicity: all operations succeed together, or none take effect at all.
Why this matters: Transactions exist specifically to protect against partial, inconsistent updates when multiple related database operations must succeed or fail together.
2. Which TypeORM class provides explicit, manual control over starting, committing, and rolling back a transaction?
- Repository
- EntityManager (used directly)
- QueryRunner
- DataSource (used alone, without QueryRunner)
Answer: C. QueryRunner
Explanation: QueryRunner is the TypeORM class specifically designed to give explicit, manual control over a single database connection, including starting a transaction, committing it, and rolling it back if needed.
Concept link: QueryRunner workflow: connect() → startTransaction() → perform operations via manager → commitTransaction() or rollbackTransaction() → always release() in finally.
Why this matters: TypeORM's QueryRunner gives explicit, fine-grained control over exactly when a transaction begins, commits, or rolls back.
3. What method should be called if an error occurs partway through a transaction?
- commitTransaction()
- rollbackTransaction()
- release()
- connect()
Answer: B. rollbackTransaction()
Explanation: rollbackTransaction() undoes all changes made within the transaction since it began, which is exactly the correct response when an error occurs partway through a multi-step transactional operation.
Concept link: Operations must use queryRunner.manager (not the regular injected repository) to actually participate in the transaction.
Why this matters: Every transactional operation must consistently use the same QueryRunner's manager, not a mix of it and regular repositories.
4. Why must queryRunner.release() be called in a finally block rather than just after commitTransaction()?
- To improve query performance
- To ensure the connection is always returned to the pool, whether the transaction succeeded or failed
- To automatically retry failed transactions
- It has no real purpose and is optional
Answer: B. To ensure the connection is always returned to the pool, whether the transaction succeeded or failed
Explanation: Placing release() in a finally block guarantees it runs regardless of whether the transaction committed successfully or was rolled back due to an error, preventing connection leaks in either case.
Concept link: Classic transaction use cases: fund transfers, order creation with inventory deduction, multi-table user registration.
Why this matters: Reliably releasing the QueryRunner's connection in a finally block is a small detail with serious consequences if overlooked.
Common NestJS Transactions TypeORM Mistakes to Avoid
- Performing some operations using the regular injected repository instead of queryRunner.manager, accidentally excluding them from the transaction entirely.
- Forgetting to call rollbackTransaction() in the catch block, leaving a failed transaction's partial changes uncommitted but also not properly cleaned up.
- Omitting the finally block entirely, causing database connections to leak whenever an error occurs and release() is never reached.
- Wrapping every single database operation in a transaction unnecessarily, even simple, single-step reads or writes that don't actually require atomicity guarantees, adding needless complexity.
NestJS Transactions TypeORM: Interview Notes and Exam Tips
- Transactions guarantee atomicity: all operations succeed together, or none take effect at all.
- QueryRunner workflow: connect() → startTransaction() → perform operations via manager → commitTransaction() or rollbackTransaction() → always release() in finally.
- Operations must use queryRunner.manager (not the regular injected repository) to actually participate in the transaction.
- Classic transaction use cases: fund transfers, order creation with inventory deduction, multi-table user registration.
Key NestJS Transactions TypeORM Takeaways
- Transactions exist specifically to protect against partial, inconsistent updates when multiple related database operations must succeed or fail together.
- TypeORM's QueryRunner gives explicit, fine-grained control over exactly when a transaction begins, commits, or rolls back.
- Every transactional operation must consistently use the same QueryRunner's manager, not a mix of it and regular repositories.
- Reliably releasing the QueryRunner's connection in a finally block is a small detail with serious consequences if overlooked.
NestJS Transactions TypeORM: Summary
Database transactions guarantee atomicity: a group of related operations either all succeed together and are permanently applied, or if any single operation fails, every change made within that transaction is automatically undone. In NestJS, TypeORM's QueryRunner provides explicit, manual control over this process, requiring you to connect() and startTransaction() before performing operations through the queryRunner's manager, then either commitTransaction() on success or rollbackTransaction() within a catch block on failure. Reliably calling release() in a finally block, regardless of outcome, is essential to avoid leaking database connections over time. Classic real-world use cases like fund transfers, order processing with inventory management, and multi-table record creation all depend on this pattern to maintain data integrity under any failure scenario.