NestJSSeniorCoding
Как обрабатывать транзакции базы данных в NestJS с TypeORM?
В NestJS+TypeORM транзакции оформляют через DataSource.transaction(), QueryRunner с явным управлением или декоратор @Transaction(). Предпочтителен QueryRunner: он даёт полный контроль над коммитом, откатом и вложенными savepoint-ами.
Транзакции в NestJS с TypeORM
TypeORM предоставляет несколько механизмов для работы с транзакциями. Выбор подхода зависит от требований к контролю над транзакцией, возможности вложенных операций и тестируемости.
Подход 1: DataSource.transaction() — простой callback
Самый компактный способ для атомарных операций без сложной логики:
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { User } from './user.entity';
import { Wallet } from './wallet.entity';
@Injectable()
export class UserService {
constructor(private readonly dataSource: DataSource) {}
async createUserWithWallet(name: string, email: string): Promise<User> {
return this.dataSource.transaction(async (manager) => {
const user = manager.create(User, { name, email });
await manager.save(user);
const wallet = manager.create(Wallet, { userId: user.id, balance: 0 });
await manager.save(wallet);
return user; // при выбросе исключения — автоматический rollback
});
}
}
Подход 2: QueryRunner — полный контроль
Рекомендуется для сложных сценариев: savepoint, условный rollback, несколько этапов коммита:
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { DataSource, QueryRunner } from 'typeorm';
import { Order } from './order.entity';
import { Product } from './product.entity';
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async placeOrder(userId: string, productId: string, qty: number): Promise<Order> {
const qr: QueryRunner = this.dataSource.createQueryRunner();
await qr.connect();
await qr.startTransaction('SERIALIZABLE'); // уровень изоляции явно
try {
const product = await qr.manager.findOneOrFail(Product, {
where: { id: productId },
lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
});
if (product.stock < qty) {
throw new Error('Недостаточно товара на складе');
}
product.stock -= qty;
await qr.manager.save(product);
const order = qr.manager.create(Order, { userId, productId, qty });
await qr.manager.save(order);
await qr.commitTransaction();
return order;
} catch (err) {
await qr.rollbackTransaction();
throw new InternalServerErrorException(err.message);
} finally {
await qr.release(); // всегда освобождаем соединение
}
}
}
Подход 3: SAVEPOINT для вложенных транзакций
async complexFlow(qr: QueryRunner): Promise<void> {
await qr.startTransaction();
try {
await qr.manager.save(MainEntity, { /* ... */ });
// Создаём savepoint перед рискованной операцией
await qr.query('SAVEPOINT sp1');
try {
await qr.manager.save(RiskyEntity, { /* ... */ });
} catch {
await qr.query('ROLLBACK TO SAVEPOINT sp1'); // частичный откат
}
await qr.commitTransaction();
} catch (err) {
await qr.rollbackTransaction();
throw err;
} finally {
await qr.release();
}
}
Инкапсуляция через декоратор (DRY-подход)
// transaction.decorator.ts
import { DataSource } from 'typeorm';
export function Transactional() {
return function (
_target: object,
_key: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value;
descriptor.value = async function (...args: unknown[]) {
const dataSource: DataSource = this.dataSource; // инжектируется в класс
return dataSource.transaction((manager) =>
original.apply({ ...this, manager }, args),
);
};
return descriptor;
};
}
Уровни изоляции в TypeORM
await qr.startTransaction('READ COMMITTED'); // по умолчанию в PostgreSQL
await qr.startTransaction('REPEATABLE READ');
await qr.startTransaction('SERIALIZABLE'); // максимальная защита, больше блокировок
Подводные камни
- Утечка соединений при пропуске qr.release() — если не вызвать
release()в блокеfinally, соединение не вернётся в пул и приложение рано или поздно «зависнет» из-за исчерпания пула. - Использование обычного Repository внутри QueryRunner — инжектированный через
@InjectRepository()репозиторий не участвует в транзакции QueryRunner. Для операций в транзакции используйтеqr.manager.getRepository(Entity). - Дедлоки при SERIALIZABLE — при высокой конкурентности уровень SERIALIZABLE порождает сериализационные ошибки (
ERROR: 40001). Нужна логика повторных попыток (retry) с экспоненциальной задержкой. - Async context propagation — при передаче
QueryRunnerмежду сервисами через аргументы нарушается инкапсуляция. Рассмотрите использованиеAsyncLocalStorageили библиотекиtypeorm-transactionalдля прозрачной передачи контекста. - N+1 внутри транзакции — ленивые отношения TypeORM открывают новые соединения, игнорируя активный QueryRunner. Всегда загружайте связи явно через
relationsилиqr.manager.find(). - Тестирование транзакций — мокировать
DataSourceсложно. Удобнее поднимать реальную тестовую БД (черезtestcontainers) и откатывать каждый тест черезqr.rollbackTransaction(). - Уровень изоляции по умолчанию — TypeORM не выставляет уровень изоляции явно; используется дефолт СУБД. В PostgreSQL это READ COMMITTED, а в MySQL — REPEATABLE READ. При смешанном окружении это неожиданно.
Common mistakes
- Дает общий ответ про Node.js и не называет конкретные API NestJS.
- Не объясняет, где в lifecycle находится транзакции TypeORM в NestJS.
- Не разделяет validation, authorization, business logic и persistence.
- Игнорирует ошибки, лимиты входных данных, observability и тестирование.
What the interviewer is testing
- Может объяснить транзакции TypeORM в NestJS на примере кода.
- Называет ключевые API: DataSource.transaction().
- Использует точные API NestJS, а не вымышленные hooks/decorators/methods.
- Видит production-риски: безопасность, отказоустойчивость, логирование и тесты.