@nestjs-transactional/typeorm
v1.0.0-alpha.1
Published
TypeORM adapter for @nestjs-transactional/core — EntityManager propagation, savepoints, multi-datasource
Maintainers
Readme
@nestjs-transactional/typeorm
TypeORM adapter for @nestjs-transactional/core.
Overview
TypeOrmTransactionAdapter— implements the coreTransactionAdapterSPI over TypeORM'sDataSource. Handles BEGIN / COMMIT / ROLLBACK viaDataSource.transaction(...)and issues rawSAVEPOINT/ROLLBACK TO SAVEPOINT/RELEASE SAVEPOINTSQL for nested transactions.- Transparent transactional repositories —
@InjectRepository(Entity)instances,@InjectEntityManager() em.getRepository(E),@InjectDataSource() ds.manager.save(...), andds.getRepository(E).save(...)automatically dispatch through the active@Transactional()scope'sEntityManager. NogetCurrentEntityManager()boilerplate. Custom repositories viaRepository.extend(...)andTreeRepositorywork transparently. See Transparent transactional behaviour below. getCurrentEntityManager(dataSource?, fallback?)— escape-hatch helper that returns the transaction-awareEntityManagerfrom the current async context (or falls back todataSource.manageroutside a transaction). Mostly needed for the documented limitations below; standard injection paths cover everything else.isInTransaction(dataSource?)— predicate for the current context.TypeOrmTransactionalModule.forRoot({ dataSource?, isDefault? })— NestJS dynamic module that activates the transparent patches and registers an adapter with the coreAdapterRegistry. TheDataSourceitself resolves from DI undergetDataSourceToken(dataSource)— the same convention@nestjs/typeormuses for@InjectRepository(E, dataSource).TypeOrmTransactionalModule.forRootAsync({ useFactory, inject?, imports? })— async variant forConfigService-driven setups. Registers viaOnModuleInitto defer DataSource resolution past@nestjs/typeorm's async DataSource provider settling (Convention #22).- Multi-DataSource: call
forRootonce per dataSource. MirrorsOutboxModule(ADR-019) andTransactionalModule.
Installation
pnpm add @nestjs-transactional/typeorm @nestjs-transactional/core typeorm @nestjs/typeorm reflect-metadataCompatibility
| Peer | Supported range |
| ----------------------------------- | -------------------------- |
| Node.js | >=22.13.0 |
| typeorm | ^0.3.0 \|\| ^1.0.0 |
| @nestjs/typeorm | ^10.0.0 \|\| ^11.0.0 |
| @nestjs/common / @nestjs/core | ^10.0.0 \|\| ^11.0.0 |
| reflect-metadata | ^0.1.13 \|\| ^0.2.0 |
| rxjs | ^7.0.0 |
The TypeORM range covers both stable 0.3.x and stable 1.x
releases. CI runs the full unit and integration matrix
(testcontainers Postgres) against both TypeORM majors, so the
adapter is exercised end-to-end on every supported peer. TypeORM
nightly / beta builds are not in the declared range; install them
explicitly via pnpm.overrides if you need to pin to one.
Quick start
Minimal single-DataSource setup:
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',
url: process.env.DATABASE_URL,
entities: [User],
synchronize: false,
}),
TypeOrmModule.forFeature([User]),
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot(),
],
})
export class AppModule {}Import order matters — TransactionalModule.forRoot({ isGlobal: true })
must be present (with isGlobal) so the AdapterRegistry is visible
inside TypeOrmTransactionalModule's DI scope. The actual
DataSource is resolved via @nestjs/typeorm's
getDataSourceToken(name) — TypeOrmModule.forRoot(...) registers
it globally, so TypeOrmTransactionalModule.forRoot finds it
automatically.
Transparent transactional behaviour
Once the module is imported, every Repository reachable through the
standard @nestjs/typeorm injection paths automatically dispatches
through the active @Transactional() scope. No
getCurrentEntityManager() calls in user code:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Transactional } from '@nestjs-transactional/core';
import { Repository } from 'typeorm';
import { Order } from './order.entity';
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private readonly orderRepo: Repository<Order>,
) {}
@Transactional()
async placeOrder(dto: PlaceOrderDto): Promise<Order> {
// `orderRepo.save(...)` automatically uses the transactional
// EntityManager. If the method throws, the save rolls back.
// Outside a @Transactional scope, the same call autocommits.
return this.orderRepo.save(dto);
}
}Supported transparent patterns:
@InjectRepository(Entity) repo— the headline case.@InjectEntityManager() em.getRepository(E).save(...).@InjectDataSource() ds.getRepository(E).save(...).@InjectDataSource() ds.manager.save(Entity, ...)(the patched DataSource manager getter routes through the active EM).- Custom repositories via
Repository.extend(...). TreeRepositoryandMongoRepository(inherit fromRepository).
The mechanism is prototype-level patching modelled on the
typeorm-transactional library; patches install at module-load
time so they cover Repositories constructed by any DI factory,
regardless of resolution order.
Documented limitations
Two patterns are NOT covered by the patches and require an escape hatch:
@InjectEntityManager() em.save(Entity, ...)direct call is NOT transactional. The patches coverem.getRepository(E).save(...)(the typical pattern) but not direct method calls on the injectedEntityManager. Use the Repository pattern instead, or callgetCurrentEntityManager():@Transactional() async createUser(name: string) { // Option A — Repository pattern (recommended). return this.em.getRepository(User).save({ name }); // Option B — escape hatch. // const em = getCurrentEntityManager(); // return em.save(User, { name }); }BaseEntitystatic methods (User.save(...)etc.) are NOT supported. TheBaseEntity.useDataSource(...)API stores a captured DataSource reference that bypasses the patches. Use the Repository pattern.
The escape hatch:
import { getCurrentEntityManager } from '@nestjs-transactional/typeorm';
@Injectable()
export class RawSqlService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
@Transactional()
async runRawSql() {
// Pass `ds` as fallback so the helper returns ds.manager when
// no transaction is active (autocommit). Inside a tx, returns
// the transactional EM.
const em = getCurrentEntityManager('default', this.ds);
await em.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[100, 1],
);
}
}Multi-DataSource
@Module({
imports: [
TypeOrmModule.forRoot({ name: 'default', /* ... */ }),
TypeOrmModule.forRoot({ name: 'billing', /* ... */ }),
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot({ isDefault: true }), // 'default'
TypeOrmTransactionalModule.forRoot({ dataSource: 'billing' }), // 'billing'
],
})
export class AppModule {}Target a specific dataSource in a transactional method:
import { Transactional } from '@nestjs-transactional/core';
@Injectable()
export class BillingService {
constructor(
@InjectRepository(Invoice, 'billing')
private readonly invoiceRepo: Repository<Invoice>,
) {}
@Transactional({ dataSource: 'billing' })
async chargeCard(/* ... */) {
// Repository is bound to 'billing' DS — saves go to billing.
return this.invoiceRepo.save(/* ... */);
}
}Cross-DS isolation (DD-023): a Repository bound to dataSource A
inside a @Transactional({ dataSource: 'B' }) method autocommits —
its patched manager getter looks up the active transaction for
dataSource A, finds none, and falls back to its captured original
manager. Distributed transactions across dataSources are explicitly
NOT supported; cross-DS atomicity goes through the outbox.
Each forRoot call registers its adapter under
typeorm:${dataSource} in the core AdapterRegistry.
TransactionManager routes based on options.dataSource.
Async configuration
@Module({
imports: [
ConfigModule,
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
type: 'postgres',
url: cfg.get('DATABASE_URL'),
entities: [User],
}),
}),
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
dataSource: cfg.get('DATA_SOURCE_NAME', 'default'),
isDefault: true,
}),
}),
],
})
export class AppModule {}forRootAsync defers resolution of the dataSource name until the
factory runs. Per-DS DI tokens (getTransactionalAdapterToken(ds))
are NOT registered in the async path because NestJS provider tokens
must be declared statically — if you need direct adapter injection
by per-DS token, use sync forRoot({ dataSource }) instead.
The async path uses an OnModuleInit-driven registration class so
the DataSource resolves correctly even when paired with
TypeOrmModule.forRootAsync (whose own DataSource provider is
async). Pinned by
packages/typeorm/test/integration/forrootasync.integration.spec.ts.
Testing
Unit tests — in-memory SQLite
For fast unit tests that don't need a real database, use TypeORM's
sqljs driver:
import { DataSource } from 'typeorm';
import { TypeOrmTransactionAdapter } from '@nestjs-transactional/typeorm';
const ds = new DataSource({
type: 'sqljs',
synchronize: true,
entities: [YourEntity],
});
await ds.initialize();
const adapter = new TypeOrmTransactionAdapter(ds, 'default');Integration tests — testcontainers-node + real Postgres
Bundled helper for real Postgres integration:
import {
startPostgresContainer,
stopPostgresContainer,
createAdditionalDatabase,
} from '@nestjs-transactional/typeorm/test/setup-testcontainers';
let ctx;
beforeAll(async () => {
ctx = await startPostgresContainer({ entities: [User], synchronize: true });
});
afterAll(async () => {
await stopPostgresContainer(ctx);
});
// Multi-DS: a second database inside the same container.
const secondary = await createAdditionalDatabase(ctx, 'billing_test', {
entities: [User],
synchronize: true,
});Run integration tests:
pnpm --filter @nestjs-transactional/typeorm test:integrationThe bundled docker-compose.yml is for manual local use (psql
against a persistent instance). Testcontainers manages its own
containers and does not require compose.
Savepoints and NESTED propagation
When a method uses PropagationMode.NESTED from inside an existing
TypeORM transaction, the adapter issues a SAVEPOINT sp_<uuid-30>
statement. Rollback rolls back to the savepoint; the outer
transaction continues. Savepoint names are at most 33 characters
long — valid on Postgres, MySQL, MariaDB, SQLite, and Oracle's
identifier limit.
Worked examples
basic-transactional—@Transactional()on@InjectRepository, single DataSource. Transparent repository showcase.multi-datasource-basic— two DataSources with@Transactional({ dataSource }), no outbox.read-write-separation— master + replica, only the master getsTypeOrmTransactionalModule.async-config-from-environment—TypeOrmTransactionalModule.forRootAsyncend-to-end withConfigService+ Joi profiles.e-commerce-orders— three-DataSource flagship combining transparent repositories + per-DS outbox + CQRS + REST + Kafka externalization.
Full catalogue: examples/README.md.
Status
Alpha. Public API may change between 0.x releases.
