DEV Community

Cover image for Transactions in NestJS and TypeORM without passing the EntityManager around
José Carlos García
José Carlos García

Posted on

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);
});
Enter fullscreen mode Exit fullscreen mode

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<void> {
  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);
}
Enter fullscreen mode Exit fullscreen mode

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<void>;
  findById(id: string, manager?: EntityManager): Promise<User | null>;
  findByEmail(email: string, manager?: EntityManager): Promise<User | null>;
  update(user: User, manager?: EntityManager): Promise<void>;
  delete(id: string, manager?: EntityManager): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

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<EntityManager>();

// 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
Enter fullscreen mode Exit fullscreen mode

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<EntityManager>();

  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<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
    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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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<Model extends ObjectLiteral> {
  protected constructor(
    private readonly dataSource: DataSource,
    private readonly transactionExecutor: TransactionExecutor,
    private readonly target: EntityTarget<Model>,
  ) {}

  protected get repository(): Repository<Model> {
    const manager: EntityManager | null =
      this.transactionExecutor.getManagerIfActive();

    return manager
      ? manager.getRepository(this.target)
      : this.dataSource.getRepository(this.target);
  }
}
Enter fullscreen mode Exit fullscreen mode

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<UserModel>
  implements IUserRepository
{
  constructor(
    dataSource: DataSource,
    transactionExecutor: TransactionExecutor,
  ) {
    super(dataSource, transactionExecutor, UserModel);
  }

  async save(user: User): Promise<void> {
    const model = UserMapper.toModel(user);
    await this.repository.save(model);
  }

  async findByEmail(email: string): Promise<User | null> {
    const found = await this.repository.findOne({ where: { email } });
    return found ? UserMapper.toDomain(found) : null;
  }

  async findByEmailWithPassword(email: string): Promise<User | null> {
    const found = await this.repository
      .createQueryBuilder('user')
      .addSelect('user.password')
      .where('user.email = :email', { email })
      .getOne();

    return found ? UserMapper.toDomain(found) : null;
  }

  async updatePassword(user: User): Promise<void> {
    await this.repository.update(
      { id: user.getId() },
      { password: user.password },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The createQueryBuilder case deserves separate attention. Query builders are where manual manager propagation usually breaks, because createQueryBuilder is a method on a repository: a repository holding the wrong one builds the query on the wrong connection. Here the call is this.repository.createQueryBuilder(...) and it lands inside the transaction like everything else.

The matching port, back in the domain layer, stays clean:

export interface IUserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findByEmailWithPassword(email: string): Promise<User | null>;
  update(user: User): Promise<void>;
  updatePassword(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

No typeorm import. Nothing to pass down, nothing to forget.

Opening a transaction

The transaction opens at the entry point of the operation, because that is where an HTTP request corresponds exactly to one unit of work: everything that happens from that point on must be committed together or discarded together. In a NestJS backend that point is the controller; in a scheduled job or a queue consumer, the corresponding handler.

Injecting the executor into the controller and wrapping the body of the method is enough:

@Patch('change-password')
async changePassword(
  @Body() request: ChangePasswordRequest,
  @CurrentUser() user: User,
): Promise<void> {
  return this.transactionExecutor.execute(async () => {
    const service = new UserChangePassword(
      this.repository,
      new BcryptPasswordHasher(),
    );
    const command = new UserChangePasswordCommand(service);
    return command.execute({ request, user });
  });
}
Enter fullscreen mode Exit fullscreen mode

The callback declares no arguments. execute does hand over the EntityManager, and occasionally that is useful for a raw query, but in normal use it is ignored, which is precisely the point. UserChangePasswordCommand has no idea it is inside a transaction. Neither does UserChangePassword. Neither does the repository. From there down everything is business logic, and the writes inside that callback either land together or none of them land.

With several repositories the approach is identical, with no coordination and no unit-of-work object to assemble:

@Post()
create(@Body() body: CreateUserRequest, @CurrentUser() user: User) {
  return this.transactionExecutor.execute(async () => {
    const createUserService = new CreateUser(
      this.idGenerator,
      this.passwordHasher,
      this.userRepository,
      this.userSettingRepository,
    );
    const command = new CreateUserCommand(createUserService);
    return command.execute({ body, user });
  });
}
Enter fullscreen mode Exit fullscreen mode

Both repositories were injected long before any transaction existed. They pick it up at call time, from the ambient context.

Reads are not wrapped:

@Get(':id')
async getById(@Param('id') id: string): Promise<UserResponse> {
  const service = new UserGetById(this.repository);
  const query = new UserGetByIdQuery(service);
  return query.execute({ id }); // no transaction, same repository
}
Enter fullscreen mode Exit fullscreen mode

The same TypeOrmUserRepository, the same findById. getManagerIfActive() returns null, the base class falls back to dataSource.getRepository(...), and the read runs on a pooled connection without the cost of a transaction. No second read-only repository class is needed, and no read has to be wrapped in an unnecessary transaction to satisfy an API.

That symmetry is the property worth protecting: a repository method that behaves identically in both contexts can be called from anywhere with no prior checks.

What it buys

  • The domain port holds no infrastructure types. IUserRepository is implementable by an in-memory double in four lines, which makes application-layer tests fast and faithful.
  • No write can end up outside the transaction. The failure mode was "someone forgot to pass manager", and there is nothing left to pass.
  • One boundary, declared in plain sight. A grep for transactionExecutor.execute enumerates every unit of work in the project.
  • Adding a repository to an existing operation costs nothing. No signature changes rippling up through the service and the command.
  • It composes with everything. Query builders, raw SQL through the manager, subscribers, several repositories at once.
  • Fifteen lines of shared code, with no additional dependencies.

Three consequences of the transaction boundary, and how to fix them

The above is the clean version. The three sections that follow are what shows up once the pattern is carrying a real system, and all three come down to the same thing: the transaction begins or ends somewhere other than the code suggests.

An open transaction holds a connection from the pool

Placing the boundary at the controller has a second-order effect: it invites wrapping the entire handler. And the moment the handler calls an external service — a payment gateway, a file store, an email provider — that network call ends up inside the transaction.

@Post('payments')
create(@Body() body: CreatePaymentRequest) {
  return this.transactionExecutor.execute(async () => {
    const charge = await this.paymentGateway.charge(body); // 800 ms of network
    await this.paymentRepository.save(Payment.from(charge));
  });
}
Enter fullscreen mode Exit fullscreen mode

While that response is awaited, the QueryRunner holds a pooled connection and the locks on the rows already written. With a pool of ten connections and an 800 ms call, the ceiling lands at around twelve requests per second no matter how much CPU is idle, and any request touching one of those rows waits on a third party it has no relationship with. In Postgres the session shows as idle in transaction, which is the signal the problem is recognised by.

The fix is to move the network call outside the transaction boundary. When the third party's result is what has to be persisted, it splits into two transactions with an idempotent record in between: write the intent, call outside a transaction, confirm the result.

@Post('payments')
async create(@Body() body: CreatePaymentRequest) {
  const pending = await this.transactionExecutor.execute(async () =>
    this.paymentRepository.save(Payment.pending(body)),
  );

  const charge = await this.paymentGateway.charge(body); // outside a transaction

  return this.transactionExecutor.execute(async () =>
    this.paymentRepository.confirm(pending.id, charge),
  );
}
Enter fullscreen mode Exit fullscreen mode

Atomicity between the two halves is lost, and that is the real trade: in exchange, no connection sits waiting on a third party. The intermediate row in a pending state is what makes it possible to reconcile later if the second half never runs, which is exactly what idempotent webhook processing exists for.

The practical rule is short: only database operations go inside execute.

Failure records must be written outside the rollback

A system that records the execution of automated tasks also needs to persist the ones that fail. The natural approach is to write the failure row in the catch, using the same manager. But by then the transaction has already rolled back, and everything written with that manager is reverted along with it: the record of the failure disappears together with the failure.

The fix comes from recognising that success and failure need different managers:

} catch (err) {
  await queryRunner.rollbackTransaction();

  if (taskRun) {
    await this.persistTaskRun(
      this.dataSource.manager, // not queryRunner.manager: that transaction is gone
      taskRun,
      TaskRunStatus.FAILED,
      err,
    );
  }

  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Success rows go through queryRunner.manager and commit alongside the work. Failure rows go through this.dataSource.manager, on a separate connection, and survive the rollback. Recording outcomes requires both routes.

Nesting opens a second transaction, not a nested one

execute calls createQueryRunner() unconditionally and never checks whether a transaction is already active. As a result, this code:

await this.transactionExecutor.execute(async () => {
  await this.repository.save(order);
  await someService.doSomething(); // which internally calls execute() again
});
Enter fullscreen mode Exit fullscreen mode

produces two independent transactions on two different connections. The inner run() shadows the outer manager for its duration, so the writes inside belong to the inner transaction and commit on their own. If the outer one later throws and rolls back, the inner work stays committed. Atomicity is lost with no warning at all.

The scenario has a second consequence: the inner transaction cannot see the outer's uncommitted writes, and if it touches a row the outer holds locked, it ends up waiting on a lock held by its own caller, which in turn is waiting on it. The result is a self-deadlock that only shows up under the exact interleaving that triggers it.

If nested calls are possible in the project, execute should join the active transaction instead of starting a new one:

async execute<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
  const active = TransactionExecutor.entityManagerStorage.getStore();
  if (active) return work(active); // joins the caller's transaction

  // otherwise, open a new one
}
Enter fullscreen mode Exit fullscreen mode

That produces flat participation: the outermost execute owns the commit and inner calls run inside it. It is the correct default for most applications. If independent inner transactions are genuinely needed somewhere, they are better offered as an explicit opt-in — executeInNewTransaction() — so it is a decision someone made rather than a shape the code fell into by omission.

Keeping the boundary at the controller prevents nesting from arising, but that is a convention, and conventions are one refactor away from being broken. The guard is preferable.

Keeping the pattern from breaking

The pattern has a weakness that is not technical: it depends on every repository following it. A repository that injects Repository<UserModel> on its own and uses it directly still compiles, still passes its tests and still returns the right data. The only thing it does wrong is stay outside transactions, silently. It is the same failure mode as at the start, back through a different door.

Two tests close it. The first proves the enlistment actually works, against a real database:

it('rolls back writes made through the repository', async () => {
  const email = 'ana@example.com';
  const userRepository = app.get(TypeOrmUserRepository);
  const transactionExecutor = app.get(TransactionExecutor);

  let visibleInside = false;

  await expect(
    transactionExecutor.execute(async () => {
      await userRepository.save(aUser({ email }));
      visibleInside = (await userRepository.findByEmail(email)) !== null;
      throw new Error('forced rollback');
    }),
  ).rejects.toThrow('forced rollback');

  expect(visibleInside).toBe(true); // the write did happen
  expect(await userRepository.findByEmail(email)).toBeNull(); // and the rollback reverted it
});
Enter fullscreen mode Exit fullscreen mode

That test fails if someone breaks the enlistment, but only for the repository it covers. The second one is the one that scales: it walks the source code and checks that no repository skips the base class.

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as ts from 'typescript';

const SRC = join(process.cwd(), 'src');

function repositoryFiles(): string[] {
  return readdirSync(SRC, { recursive: true })
    .map(String)
    .map((file) => file.replaceAll('\\', '/'))
    .filter((file) => file.includes('infrastructure/typeorm/'))
    .filter((file) => file.endsWith('.repository.ts'));
}

it('every TypeORM repository extends BaseTypeOrmRepository', () => {
  const files = repositoryFiles();
  expect(files.length).toBeGreaterThan(0); // if it stops finding files, the test says so

  const offenders: string[] = [];

  for (const file of files) {
    const source = ts.createSourceFile(
      file,
      readFileSync(join(SRC, file), 'utf8'),
      ts.ScriptTarget.Latest,
    );

    source.forEachChild((node) => {
      if (!ts.isClassDeclaration(node) || !node.name) return;

      const isAbstract = node.modifiers?.some(
        (modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword,
      );
      if (isAbstract) return; // BaseTypeOrmRepository itself

      const extendsBase = node.heritageClauses?.some(
        (clause) =>
          clause.token === ts.SyntaxKind.ExtendsKeyword &&
          clause.types.some(
            (type) =>
              ts.isIdentifier(type.expression) &&
              type.expression.text === 'BaseTypeOrmRepository',
          ),
      );

      if (!extendsBase) offenders.push(`${node.name.text} (${file})`);
    });
  }

  expect(offenders).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

It is parsed with the TypeScript compiler rather than with regular expressions, because what is being looked for is an extends clause on a class declaration, and a regex confuses that with a comment or a string at the first opportunity.

With that test, the failure stops depending on someone catching it in review: the build catches it.

When not to apply it

If dataSource.transaction() is used directly in a handful of services and is causing no trouble, there is no reason to change it. This pattern pays for itself when a repository layer exists, a domain that should not know about the ORM, and enough repositories that replicating the enlistment logic is a real cost.

There are also libraries that wrap this same idea in a @Transactional() method decorator. The explicit version has two advantages: the boundary is visible at the call site instead of hidden in a decorator on a service three layers down, and extending the transaction lifecycle — to record outcomes, for instance — is a few lines in a file you own.

Checklist

The pattern is simple; the time goes into the details.

  • queryRunner.release() in finally, always, or connections leak on the error path.
  • The commit inside the run() callback, not after it.
  • getManagerIfActive() returns null instead of throwing, so repositories work outside transactions.
  • The AsyncLocalStorage field must be static: the store has to be unique for the whole process.
  • Only database operations go inside execute: a network call holds a pooled connection and the locks while it waits.
  • Failure records are written with dataSource.manager, not the already-rolled-back queryRunner.manager.
  • Behaviour under nesting is decided explicitly and enforced inside execute, not by convention.
  • An architecture test keeps a new repository from skipping the base class.

The best possible outcome is that nobody on the team ever has to think about any of this. They write repositories that look like ordinary repositories, use cases that look like ordinary use cases, and no signature mentions an EntityManager because there is no longer one to pass around.

Top comments (0)