Transactions
Wrapping a unit of work in one TypeORM transaction, and why the transaction belongs inside a single service method.
dataSource.transaction([isolationLevel,] (manager: EntityManager) => Promise<T>): Promise<T>A transaction is a group of statements the database commits together or discards together. In Nest the usual unit is one service method: check stock, decrement it, insert the order. Half of that is worse than none of it.
The two forms
dataSource.transaction(callback) is the short form. It hands your callback an EntityManager bound to one connection, commits when the callback resolves, and rolls back if it throws. The long form is a QueryRunner: createQueryRunner(), connect(), startTransaction(), then commitTransaction() or rollbackTransaction() in a catch, and release() in a finally. They do the same thing; the callback simply writes the bookkeeping for you. Pass an isolation level as the first argument: transaction('SERIALIZABLE', callback).
Why it stays in one method
A transaction holds a connection and locks rows, so its lifetime must be short and visible in one place. Opening one in an interceptor (a class that wraps the handler call, running before pipes validate the body and again after the handler returns) means the transaction is open during validation and response mapping. Opening one in a guard (the class that runs before interceptors to allow or deny the request) is worse still. Order of the lifecycle: middleware, guards, interceptors, pipes, handler, interceptors again, filters.
What trips people up
Throwing is how you roll back. The exception then leaves the service, reaches your exception filter, and becomes the HTTP response.
Example
// src/orders/orders.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Order } from './order.entity.js';
import { StockItem } from './stock-item.entity.js';
Injectable()
export class OrdersService {
// DataSource is a provider created by TypeOrmModule.forRoot().
// For a second, named data source, inject it with @InjectDataSource('name').
constructor(private readonly dataSource: DataSource) {}
async placeOrder(customerId: number, sku: string, quantity: number): Promise<Order> {
// One unit of work. REPEATABLE READ so the stock row we checked cannot
// change underneath us before the decrement.
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
// Every statement goes through `manager`, never through an injected
// repository, or it would run outside this transaction.
const stock = await manager.getRepository(StockItem).findOne({
where: { sku },
lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
});
if (!stock || stock.quantity < quantity) {
// Throwing rolls everything back, then travels to the exception filter.
throw new BadRequestException(`Not enough stock for ${sku}`);
}
await manager.decrement(StockItem, { sku }, 'quantity', quantity);
const orders = manager.getRepository(Order);
const order = orders.create({ customerId, sku, quantity });
return orders.save(order); // committed when this callback resolves
});
}
}When to use it
- Reserving stock and inserting an order so that neither can happen alone.
- Moving a balance between two rows under SERIALIZABLE isolation.
- Rolling back a multi-table write when a validation failure is discovered halfway through.
- Unit-testing a method that transacts, by replacing the DataSource provider.
More examples
The same work written with a QueryRunner
This is exactly what dataSource.transaction() does internally. Write it out when you need the handle itself: to run a savepoint, to issue a raw statement with queryRunner.query, or to keep the transaction open across two private helpers. The cost is that you now own release(), and a forgotten one exhausts the pool under load.
// src/orders/orders.manual.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Order } from './order.entity.js';
import { OrderItem } from './order-item.entity.js';
@Injectable()
export class OrdersManualService {
constructor(private readonly dataSource: DataSource) {}
async createWithItems(order: Order, items: OrderItem[]): Promise<Order> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); // take a connection from the pool
await queryRunner.startTransaction(); // BEGIN
try {
const saved = await queryRunner.manager.save(order);
for (const item of items) {
item.orderId = saved.id;
await queryRunner.manager.save(item);
}
await queryRunner.commitTransaction(); // COMMIT
return saved;
} catch (err) {
await queryRunner.rollbackTransaction(); // ROLLBACK
throw err; // rethrow: swallowing it would return a half-written order
} finally {
// A manually created query runner must always be released,
// or the connection never returns to the pool.
await queryRunner.release();
}
}
}Testing a service that transacts, without a database
Because the transaction is one call on an injected DataSource, the whole method is testable by replacing that provider. Note how much of the fake exists only to absorb the optional isolation-level argument: that awkwardness is the argument for a narrow transaction helper, as the Nest documentation suggests, rather than mocking DataSource everywhere.
// src/orders/orders.service.spec.ts
import { vi } from 'vitest'; // Jest in a CommonJS project; the Nest API is the same
import { Test } from '@nestjs/testing';
import { DataSource, EntityManager } from 'typeorm';
import { OrdersService } from './orders.service.js';
import { StockItem } from './stock-item.entity.js';
describe('OrdersService', () => {
let service: OrdersService;
// A fake manager: just the methods this service actually calls.
const manager = {
getRepository: vi.fn(),
decrement: vi.fn(),
} as unknown as EntityManager;
const dataSource = {
// Run the callback immediately with the fake manager: no BEGIN, no COMMIT.
transaction: vi.fn((levelOrCb: unknown, maybeCb?: unknown) => {
const cb = (typeof levelOrCb === 'function' ? levelOrCb : maybeCb) as
(m: EntityManager) => Promise<unknown>;
return cb(manager);
}),
} as unknown as DataSource;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
OrdersService,
// Replace the real DataSource provider with the fake, by token.
{ provide: DataSource, useValue: dataSource },
],
}).compile();
service = moduleRef.get(OrdersService);
});
it('rejects an order when stock is short', async () => {
(manager.getRepository as ReturnType<typeof vi.fn>).mockReturnValue({
findOne: vi.fn().mockResolvedValue({ sku: 'A1', quantity: 1 }),
});
await expect(service.placeOrder(7, 'A1', 5)).rejects.toThrow('Not enough stock');
expect(manager.decrement).not.toHaveBeenCalled();
});
});The same unit of work in Prisma 7
Prisma's two forms mirror TypeORM's: the callback when later statements depend on earlier results, and the array when they do not. The same rule applies, for the same reason: inside the callback use tx, because this.prisma holds a different connection and its writes will not roll back with the rest.
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import type { User } from '../generated/prisma/client.js';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// Interactive transaction: `tx` is a client scoped to the transaction.
// Use tx, not this.prisma, for every statement inside.
deleteUserWithPosts(id: number): Promise<User> {
return this.prisma.$transaction(async (tx) => {
await tx.post.deleteMany({ where: { authorId: id } });
return tx.user.delete({ where: { id } });
});
}
// Sequential form: an array of operations, run in one transaction,
// results returned in the same order. No callback, so no branching.
publishedPosts(skip: number, take: number) {
return this.prisma
.$transaction([
this.prisma.post.findMany({ where: { published: true }, skip, take }),
this.prisma.post.count({ where: { published: true } }),
])
.then(([posts, total]) => ({ posts, total }));
}
}
Discussion