@nestjs-transactional/typeorm
v2.0.0
Published
TypeORM adapter for @nestjs-transactional/core — EntityManager propagation, savepoints, multi-datasource
Maintainers
Readme
@nestjs-transactional/typeorm
TypeORM adapter for
@nestjs-transactional/core.
Two things come with it. The adapter itself, which maps @Transactional()
onto TypeORM's transactions and savepoints — and transparent
transactional repositories: your existing @InjectRepository(Order)
instances start honouring the active transaction on their own, with no
change to the code that uses them.
@Injectable()
export class OrderService {
constructor(@InjectRepository(Order) private readonly orders: Repository<Order>) {}
@Transactional()
async place(dto: PlaceOrderDto) {
// Runs in the transaction. Rolls back if anything below throws.
// Outside a @Transactional method, the same call autocommits.
return this.orders.save(dto);
}
}No getCurrentEntityManager(), no passing an EntityManager down
through service layers, no separate "transactional" repository type.
Install
pnpm add @nestjs-transactional/typeorm @nestjs-transactional/core typeorm @nestjs/typeorm reflect-metadataModule format
This package ships ESM only, matching NestJS 12, which is ESM-only across its own packages. There is no CommonJS build.
A CommonJS application still works: Node loads ESM from require()
since 22.12.0, which is why engines.node is >=22.13.0. What does not
follow Node here is tooling with its own module loader — Jest above all,
which needs NODE_OPTIONS=--experimental-vm-modules and a few config
settings. The 19 example applications in the repository all run their
suites that way and can be copied from.
Reasoning and measurements: ADR-022.
Quick start
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransactionalModule } from '@nestjs-transactional/core';
import { TypeOrmTransactionalModule } from '@nestjs-transactional/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({ type: 'postgres', entities: [Order] }),
TypeOrmModule.forFeature([Order]),
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot(),
],
})
export class AppModule {}TransactionalModule.forRoot({ isGlobal: true }) has to be there, with
isGlobal — that is how this module sees the core registry from its own
DI scope. The DataSource is found through @nestjs/typeorm's
getDataSourceToken(name), the same convention
@InjectRepository(E, dataSource) uses, so nothing else needs wiring.
What becomes transparent
These all dispatch through the active transaction:
@InjectRepository(Entity)— the common case.@InjectEntityManager() em.getRepository(E).@InjectDataSource() ds.getRepository(E)andds.manager.- Custom repositories built with
Repository.extend(...). TreeRepositoryandMongoRepository, which inherit fromRepository.
Two patterns are not covered, and need an escape hatch:
em.save(Entity, ...)called directly on an injectedEntityManager. The patch coversem.getRepository(E).save(...), not the manager's own data methods — patching all ~14 of them would require per-method recursion guards, which was judged not worth the surface area.BaseEntitystatic methods (User.save(...)).BaseEntity.useDataSource(...)captures aDataSourcereference that bypasses the patch. The librarytypeorm-transactionalhas the same limitation.
For both, either use a repository or reach for the escape hatch:
import { getCurrentEntityManager } from '@nestjs-transactional/typeorm';
@Transactional()
async runRawSql() {
// Pass the DataSource as fallback so this also works outside a
// transaction, where it returns ds.manager.
const em = getCurrentEntityManager('default', this.ds);
await em.query('UPDATE accounts SET balance = balance - $1', [100]);
}Dialect-dependent behaviour
Two options behave differently per database, and both fail loudly or harmlessly rather than surprisingly:
readOnlyis enforced onpostgres,cockroachdbandaurora-postgres, where the adapter issuesSET TRANSACTION READ ONLYas the transaction's first statement and the database refuses a write. On other dialects it is a silent no-op. MySQL is not merely unimplemented but unimplementable:SET TRANSACTIONthere applies to the next transaction and errors inside a started one. Worth knowing if you develop on SQLite and deploy to Postgres — the constraint appears in production for the first time. (DD-027)PropagationMode.NESTEDneeds savepoints. The adapter checks TypeORM's owndriver.transactionSupportflag and throwsIllegalTransactionStateErrornaming the driver and the alternatives, instead of running your "nested" transaction as part of the outer one.
Multiple dataSources
One forRoot call per dataSource:
TypeOrmTransactionalModule.forRoot({ isDefault: true }), // 'default'
TypeOrmTransactionalModule.forRoot({ dataSource: 'billing' }), // 'billing'@Transactional({ dataSource: 'billing' })
async chargeCard() {
return this.invoiceRepo.save(/* ... */); // repo bound to 'billing'
}A repository bound to dataSource A, used inside a
@Transactional({ dataSource: 'B' }) method, autocommits: its patched
manager looks for an active transaction on A, finds none, and falls back
to its original manager. Distributed transactions across dataSources
are not supported — that is deliberate, and cross-dataSource atomicity
is what the outbox is for.
Async configuration
TypeOrmTransactionalModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
dataSource: cfg.get('DATA_SOURCE_NAME', 'default'),
isDefault: true,
}),
});Registration is deferred to OnModuleInit so the dataSource resolves
correctly even when paired with TypeOrmModule.forRootAsync. Per-dataSource
adapter tokens are not registered on this path, because NestJS needs
provider tokens at module-definition time; use sync forRoot({ dataSource })
if you inject adapters by token.
Compatibility
| Peer | Supported range |
| --- | --- |
| Node.js | >=22.13.0 |
| typeorm | ^0.3.0 \|\| ^1.0.0 |
| @nestjs/typeorm | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 |
| @nestjs/common / @nestjs/core | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 |
| reflect-metadata | ^0.1.13 \|\| ^0.2.0 |
| rxjs | ^7.0.0 |
Both stable TypeORM lines are supported. CI runs the full unit and
integration matrix — including savepoints and isolation against a real
Postgres — at three points of that range: 0.3.31, 1.0.0 and 1.1.0.
Testing
For unit tests, TypeORM's in-memory sqljs driver is enough:
const ds = new DataSource({ type: 'sqljs', synchronize: true, entities: [Order] });
await ds.initialize();
const adapter = new TypeOrmTransactionAdapter(ds, 'default');Note that readOnly is not enforced on sqljs, so a read-only
violation your tests miss can still surface on Postgres. For tests that
need real dialect behaviour, run Postgres through
testcontainers; the
testing-patterns
example shows both layers.
Documentation
- Getting started and full docs
readOnlyandtimeoutsemantics (DD-027)- Multi-adapter architecture (ADR-018)
- Known limitations
- Runnable examples:
basic-transactional,multi-datasource-basic,read-write-separation,e-commerce-orders
License
MIT
