Transactions in NestJS and TypeORM Without Passing the EntityManager Around
Transactions promise a simple guarantee: either everything commits, or nothing does. And yet, in a NestJS application with a repository layer, it is perfectly possible to run a rollback with no errors and then find a row still sitting in the database that should have disappeared with it. This is not a TypeORM or PostgreSQL bug. One of the repositories involved was never inside the transaction, because the EntityManager stopped being passed down three layers up. There was no exception, no warning, and the tests passed because that repository was mocked. This article describes how to make that class of failure impossible: the transaction opens at a single point - the controller handling the request - and repositories enlist themselves in the transaction in progress, without receiving anything as a parameter. It comes to about sixty lines built on AsyncLocalStorage . The second part is the one rarely told: three consequences of the transaction boundary, each with its fix. A network call inside the transaction holds a pooled connection and its locks for the entire wait. A failure record written in the catch is rolled back along with the very failure it was meant to document. And nesting two execute calls does not open a nested transaction but two independent ones, with the self-deadlock that allows. The problem: passing the EntityManager by hand TypeORM offers a transaction like this: await dataSource.transaction(async (manager) => { await manager.getRepository(UserModel).save(user); await manager.getRepository(UserSettingModel).save(settings); }); For a small project this is the correct answer and nothing more is needed. The problem shows up once a repository layer exists. The manager is the transaction: if a repository does not use that manager, its queries run on a different connection and end up outside the transaction. Silently, with no error and no warning. The rollback simply does not revert them. So the manager has to reach the repository, and it only gets there by being passed by hand. An application-layer use case ends up like this: async execute(props: CreateUserProps, manager?: EntityManager): Promise { const user = await this.userRepository.findByEmail(props.email, manager); if (user) throw new UserAlreadyExistsError(); await this.userRepository.create(newUser, manager); await this.settingRepository.create(defaultSettings, manager); } The effect on UserRepository , the interface that declares the port and lives in the domain layer, is the following: export interface UserRepository { create(user: User, manager?: EntityManager): Promise ; findById(id: string, manager?: EntityManager): Promise ; findByEmail(email: string, manager?: EntityManager): Promise ; update(user: User, manager?: EntityManager): Promise ; delete(id: string, manager?: EntityManager): Promise ; } This interface lives in the domain layer. The reason for putting it there is that the domain declares what it needs without knowing how anything is persisted. And now it imports EntityManager from typeorm . With that, the port stops being one: it can no longer be implemented without dragging the ORM along, starting with the in-memory double you would use to test the application layer. The underlying problem is not aesthetic. That ? carries the correctness of the system and is invisible: failing to pass manager through one call, inside one branch, of one service is enough for that write to fall outside the transaction. It passes code review. It passes the tests that mock the repository. And it surfaces in production with symptoms like this one, by way of example: a half-failed operation that leaves a user row without its matching settings row. The second alternative - doing without transactions - resolves nothing. It only postpones the problem to the moment two related writes diverge. The goal, then: - The domain port with no TypeORM types. - The transaction boundary declared once, at the entry point. - Repositories that enlist themselves in the active transaction, and behave identically when there is none. - "Forgetting to pass something" no longer a possible mistake, because nothing is passed. AsyncLocalStorage Node has shipped AsyncLocalStorage since v12. It is storage local to the asynchronous call chain: a value is set at the root, and any function below it - at any depth, across every await - can read it without receiving it as an argument. Applied to the problem above, the shape is this: const storage = new AsyncLocalStorage (); // At the root of the operation, once: await storage.run(queryRunner.manager, async () => { await createUserService.execute(props); // does not receive the manager }); // Several layers down, inside any repository: const manager = storage.getStore(); // the same queryRunner.manager from above The store is scoped to the asynchronous context, not to a global variable. Two concurrent HTTP requests each hold their own, with no interference, and outside a run() the read returns undefined . That property is what makes the pattern safe. An EntityManager fits that description exactly: it is needed throughout the call chain and belongs to none of the intermediate layers. The solution: TransactionExecutor TransactionExecutor lives in the project's shared infrastructure. It is forty-five lines, with no dependencies beyond TypeORM and Node. import { Injectable } from '@nestjs/common'; import { AsyncLocalStorage } from 'async_hooks'; import { DataSource, EntityManager } from 'typeorm'; @Injectable() export class TransactionExecutor { private static readonly entityManagerStorage = new AsyncLocalStorage (); constructor(private readonly dataSource: DataSource) {} /** * Runs work inside a transaction. Commits if it completes, * rolls back if it throws, and always releases the queryRunner. / async execute (work: (manager: EntityManager) => Promise ): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { return await TransactionExecutor.entityManagerStorage.run( queryRunner.manager, async () => { const result = await work(queryRunner.manager); await queryRunner.commitTransaction(); return result; }, ); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } /* * EntityManager of the active transaction, or null if there is none. * Repositories use it to enlist in the transaction in progress. */ getManagerIfActive(): EntityManager | null { return TransactionExecutor.entityManagerStorage.getStore() || null; } } Four decisions in the code above are deliberate, and each one produces a bug if resolved differently: The storage is static . A single store must exist for the whole process: with two distinct AsyncLocalStorage objects, a repository reading from the second would not see the transaction opened with the first. Sharing it does not compromise isolation, because run() takes care of that: it scopes the value to its callback's asynchronous chain and restores the previous one on return, so two concurrent requests never see each other's manager. The commit happens inside the run() callback, not after it. With the commit outside, any code sitting between the end of work() and the commit observes a store that has already been torn down. The release happens in finally . A QueryRunner holds a real connection from the pool. Omitting this on the error path leaks one connection per failed request, until the pool is exhausted and the application stops responding. It is the most common way to get this pattern wrong. getManagerIfActive() returns null instead of throwing. That is what allows the same repository to work outside a transaction too. The abstraction: BaseTypeOrmRepository Every repository needs to resolve the same decision: if a transaction is active, obtain the repository from its manager; otherwise use the one from the DataSource . Repeating that check in every file is exactly the kind of duplication that ends up failing in a single place, silently. That decision belongs in a base class, sitting next to the executor in the shared infrastructure: import { DataSource, EntityManager, EntityTarget, ObjectLiteral, Repository, } from 'typeorm'; import { TransactionExecutor } from './typeorm-transaction.executor'; export abstract class BaseTypeOrmRepository { protected constructor( private readonly dataSource: DataSource, private readonly transactionExecutor: TransactionExecutor, private readonly target: EntityTarget , ) {} protected get repository(): Repository { const manager: EntityManager | null = this.transactionExecutor.getManagerIfActive(); return manager ? manager.getRepository(this.target) : this.dataSource.getRepository(this.target); } } That is the entire abstraction, in fifteen lines, and it is worth pausing on what it does and what it deliberately does not do. It is not a generic CRUD base class. It implements no save , no find , no other operation. It exposes one single thing - the repository already enlisted in the right transaction - and steps aside. Each concrete repository goes on writing whatever queries it needs with the usual TypeORM API, with no intermediate layer to translate and no new limitations. It is a get , not a method. Subclasses write this.repository , which reads exactly like the injected repository it replaces. There is no new convention to learn and nothing to remember on each query, and that detail is what makes the abstraction impossible to misuse by accident. The decision lives in one place in the project. The question "is there an active transaction?" is answered once, in fifteen lines, and no repository ever asks it again. Adding a new repository means extending the class and passing the model to super : from then on it takes part in transactions without a single extra line of code. A complete example, the user repository in the infrastructure layer: @Injectable() export class TypeOrmUserRepository extends BaseTypeOrmRepository implements IUserRepository
Comments
No comments yet. Start the discussion.