TypeORM Transactions
A transaction groups several database writes into a single atomic unit: either every statement commits together, or none of them do. In real applications this is what guarantees that a money transfer never debits one account without crediting the other, or that an order and its line items are saved as one consistent record. TypeORM gives you three ways to manage transactions in NestJS — the dataSource.transaction() callback, a manually controlled QueryRunner, and the declarative @Transactional() decorator from typeorm-transactional. This page covers all three and when to reach for each.
The transaction callback
The simplest approach is DataSource.transaction(). It opens a transaction, hands you an EntityManager bound to that transaction, commits automatically when your callback resolves, and rolls back automatically if it throws. You never call commit or rollback yourself.
Inject the DataSource into your service and run all your work through the supplied manager:
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Account } from './account.entity';
@Injectable()
export class TransferService {
constructor(private readonly dataSource: DataSource) {}
async transfer(fromId: number, toId: number, amount: number): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const from = await manager.findOneByOrFail(Account, { id: fromId });
const to = await manager.findOneByOrFail(Account, { id: toId });
if (from.balance < amount) {
throw new Error('Insufficient funds');
}
from.balance -= amount;
to.balance += amount;
await manager.save([from, to]);
});
}
}
Always use the
managerargument inside the callback — not an injected repository. A repository obtained outside the callback runs on its own connection and will not participate in the transaction, silently breaking atomicity.
If you need a specific isolation level, pass it as the first argument:
await this.dataSource.transaction('SERIALIZABLE', async (manager) => {
// ... transactional work runs at SERIALIZABLE isolation
});
QueryRunner with manual commit and rollback
When you need fine-grained control — for example, doing non-database work between steps, or branching your commit logic — use a QueryRunner. You are responsible for connecting, starting the transaction, committing or rolling back, and releasing the runner. A try/catch/finally block is mandatory so the connection is always returned to the pool.
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Order } from './order.entity';
import { OrderItem } from './order-item.entity';
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async placeOrder(order: Order, items: OrderItem[]): Promise<Order> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const savedOrder = await queryRunner.manager.save(order);
for (const item of items) {
item.order = savedOrder;
await queryRunner.manager.save(item);
}
await queryRunner.commitTransaction();
return savedOrder;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
}
Output:
[Nest] LOG [OrderService] START TRANSACTION
[Nest] LOG [OrderService] INSERT INTO "order" ...
[Nest] LOG [OrderService] INSERT INTO "order_item" ...
[Nest] LOG [OrderService] COMMIT
If any save throws, rollbackTransaction() undoes every insert in the same unit and the COMMIT never runs.
Declarative transactions with typeorm-transactional
Threading a manager or QueryRunner through many service methods is repetitive. The typeorm-transactional library adds a @Transactional() decorator that wraps a method in a transaction transparently, propagating it across nested service calls using AsyncLocalStorage. Your repositories work as usual — no manager passing required.
First install the package and initialize its storage before the Nest app bootstraps:
npm install typeorm-transactional
// main.ts
import { initializeTransactionalContext } from 'typeorm-transactional';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
initializeTransactionalContext();
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
Register the DataSource so the library can attach its transactional wrapper:
// app.module.ts
import { addTransactionalDataSource } from 'typeorm-transactional';
import { DataSource } from 'typeorm';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'postgres',
host: 'localhost',
entities: [Account],
synchronize: false,
}),
dataSourceFactory: async (options) => {
if (!options) throw new Error('Invalid TypeORM options');
return addTransactionalDataSource(new DataSource(options));
},
}),
],
})
export class AppModule {}
Now annotate any service method. Everything it touches — including nested service methods that also use injected repositories — runs in one transaction:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Transactional, Propagation } from 'typeorm-transactional';
import { Account } from './account.entity';
@Injectable()
export class TransferService {
constructor(
@InjectRepository(Account)
private readonly accounts: Repository<Account>,
) {}
@Transactional({ propagation: Propagation.REQUIRED })
async transfer(fromId: number, toId: number, amount: number): Promise<void> {
const from = await this.accounts.findOneByOrFail({ id: fromId });
const to = await this.accounts.findOneByOrFail({ id: toId });
from.balance -= amount;
to.balance += amount;
await this.accounts.save([from, to]);
}
}
Choosing an approach
| Approach | Control | Boilerplate | Best for |
|---|---|---|---|
dataSource.transaction() | Automatic commit/rollback | Low | Most service-level atomic operations |
QueryRunner | Full manual control | High | Complex flows, custom commit branching, savepoints |
@Transactional() | Automatic + propagation | Lowest | Apps using repositories across many nested services |
Best practices
- Keep transactions short — open them as late as possible and avoid network calls or heavy CPU work inside them to reduce lock contention.
- Inside
dataSource.transaction()andQueryRunner, only use the suppliedmanager; injected repositories run outside the transaction. - Always wrap
QueryRunnerwork intry/catch/finallyand callrelease()infinallyso connections return to the pool. - Let exceptions propagate to trigger rollback rather than swallowing them; the callback and decorator both roll back on any thrown error.
- Pick an explicit isolation level (
READ COMMITTED,SERIALIZABLE, etc.) when correctness depends on it, and be ready to retry serialization failures. - Prefer
@Transactional()for large codebases to avoid manager threading, but callinitializeTransactionalContext()before app creation or it silently does nothing.