← Back to list

A clean @Transactional() Decorator for NestJS + TypeORM Without passing EntityManager

Transaction management is easy to make ugly. A method needs a transaction, so you thread a queryRunner or an EntityManager through every…

Md. Jubaer Hosain · 2026-07-09 12:37 · 0 claps · 3.6 min read
#nestjs #typeorm #transactional #typescript #database
Open on Medium ↗
Wiki topics: BIZ · Business Strategy 🌐 · Web Development

A clean @Transactional() Decorator for NestJS + TypeORM Without passing EntityManager

Transaction management is easy to make ugly. A method needs a transaction, so you thread a queryRunner or an EntityManager through every call, split methods to accept one, and let a database concern leak into code that never cared about it.

I wanted it to be invisible instead. Write the method, mark it, move on:


@Injectable()
export class MemberService {
  constructor(
      @InjectRepository(Member) private readonly repo: Repository<Member>,
      private readonly accountingService: AccountingService
) {}

  @Transactional()
  async register(name: string) {
    const member = await this.repo.save({ name });
    await this.accountingService.openAccount(member);
    return member;
  }
}

The repository I already injected runs inside the transaction. When I call accountingService.openAccount(), that service joins the same one, and if anything throws it all rolls back together — including writes a couple of services away. Nothing is passed around; the code reads like there’s no transaction at all.

This DX isn’t new. An older library (typeorm-transactional) popularized it years ago, but it has since gone quiet, and the way it worked never sat right with me: at startup it rewrote TypeORM’s own classes to slip transactions in (a trick called monkey-patching), and changing a library’s code from the outside means an upgrade can break your app in ways that are hard to trace.

The feel was right; I just wanted it on something maintained, without the rewriting. So I built ***@nestjs-transactions/typeorm***.

The gap it fills

The maintained foundation for this in NestJS is ***@nestjs-cls/transactional. It handles propagation properly through CLS (AsyncLocalStorage), without patching anything. The catch is that it doesn’t let you keep working through your repositories — you inject a TransactionHost*** and reach through it on every query:


constructor(private readonly txHost: TransactionHost<TransactionalAdapterTypeOrm>) {}

@Transactional()
async register(name: string) {
  const member = await this.txHost.tx.getRepository(Member).save({ name });
  // txHost.tx.getRepository(…) again in every method
}

Correct, but noisy — adopting it across an existing codebase means rewriting the data layer by hand, when all I wanted was to keep @InjectRepository and change what sits underneath it.

That’s the whole idea: the repository you inject should always talk to whichever EntityManager is active — transactional inside a transaction, plain outside. NestJS already lets you decide what a token resolves to, so you register a small proxy under TypeORM’s own repository token and re-resolve it to the active manager on each call. @nestjs-cls/transactional does the transaction work underneath; the familiar ergonomics sit on top.

Using it

Everything is a peer dependency — the package ships no runtime code of its own:

npm install @nestjs-transactions/typeorm @nestjs-transactions/core \
@nestjs-cls/transactional @nestjs-cls/transactional-adapter-typeorm nestjs-cls

Keep your TypeOrmModule.forRoot() — it still owns the connection and pool — and add one module beside it for transaction propagation:

// app.module.ts
@Module({
imports: [TypeOrmModule.forRoot({/* … */}), TransactionalModule.forRoot()],
})
export class AppModule {}

Use TransactionalModule.forFeature([Member]) wherever you’d have written TypeOrmModule.forFeature([Member]):

// member.module.ts
@Module({
imports: [TransactionalModule.forFeature([Member])],
providers: [MemberService, AccountingService],
})
export class MemberModule {}

And the service from the top of this post doesn’t change at all:

import { Transactional } from '@nestjs-transactions/typeorm';

@Injectable()
export class MemberService {
  constructor(
  @InjectRepository(Member) private readonly repo: Repository<Member>,
  private readonly accounting: AccountingService,
  ) {}

  @Transactional()
  async register(name: string) {
    const member = await this.repo.save({ name });
    await this.accounting.openAccount(member); // same transaction, no decorator here
    return member;
    }
}

Outside a transaction the repository behaves like an ordinary TypeORM repository, because the proxy simply resolves to the ordinary manager.

How it works

When @Transactional() runs, it opens a transaction and stores the active EntityManager in an AsyncLocalStorage context. Every service further down the async chain reads the same context — that’s how one transaction follows a call three services deep without anything being passed along.

The piece I added is the proxy. TransactionalModule.forFeature([Member]) registers a provider under the exact token @InjectRepository(Member) already resolves, and its value re-resolves to the active manager’s repository whenever you touch it — transactional inside @Transactional, base outside. Nothing is patched; it’s plain dependency injection pointed at a smarter provider, so when TypeORM changes under it, nothing breaks.

What you get

The features you’d reach for are all here: propagation modes (***REQUIRED***, ***REQUIRES_NEW***, ***NESTED***, ***MANDATORY***, ***NEVER***, ***SUPPORTS***, ***NOT_SUPPORTED***), isolation levels, multiple data sources, and lifecycle hooks that fire only once a transaction settles:

@Transactional()
async register(name: string) {
  const member = await this.repo.save({ name });
  runOnTransactionCommit(() => this.mailer.sendWelcome(member)); // only after COMMIT
  runOnTransactionRollback((err) => this.metrics.registrationFailed(err));
  return member;
}

Unit tests need no database — a no-op module runs @Transactional() methods straight through while @InjectRepository resolves to your mock:

import { createNoOpTypeOrmTransactionalModule } from '@nestjs-transactions/typeorm/testing';
const repoMock = { save: jest.fn() };
const moduleRef = await Test.createTestingModule({

imports: [
  createNoOpTypeOrmTransactionalModule({
  manager: { getRepository: () => repoMock },
  entities: [Member],
  }),
],
providers: [MemberService],
}).compile();

Where it bites

A Promise.all of queries inside one transaction runs over a single connection. That’s a TypeORM/driver constraint true of every approach, not this package — await sequentially inside a transaction, or use Propagation.REQUIRES_NEW for genuine independence.

A repository built with repo.extend() or a hand-written class holds a fixed EntityManager, so the proxy can’t step in. For those, extend the provided TransactionalRepository base class.

And don’t register the same entity through both TypeOrmModule.forFeature and TransactionalModule.forFeature in one module — they claim the same token and the last one wins.

The honest trade is maturity: this is a younger, smaller package, and it asks you to adopt the @nestjs-cls/transactional stack it sits on. In exchange you get familiar ergonomics on a maintained foundation, without the patching the old approach relied on.

Try it

It’s on npm as ***@nestjs-transactions/typeorm, with source at [github.com/jubaerhosain/nestjs-transactions](https://github.com/jubaerhosain/nestjs-transactions)***.

If you try it, I’d like to hear where it held up and where it didn’t.


메타데이터
post_id
b86cbf72f262
slug
a-clean-transactional-decorator-for-nestjs-typeorm-b86cbf72f262
url
https://medium.com/@jubaerhosain1119/a-clean-transactional-decorator-for-nestjs-typeorm-b86cbf72f262
canonical_url
https://medium.com/@jubaerhosain1119/a-clean-transactional-decorator-for-nestjs-typeorm-b86cbf72f262
author_url
https://medium.com/@jubaerhosain1119
status
ok
fetched_at
2026-07-13 06:23:13