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…
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