Transactions Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
transactions-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Four modules compose for declarative transactions:
@modularityjs/plugins—PluginsModule, the method-interception seam@Transactionalrides on.@modularityjs/database— the contract:TransactionRunner.run(options, fn)implements the portable propagation semantics (REQUIREDjoins an ambient transaction,REQUIRES_NEWalways opens a fresh one; commit on return, roll back on throw) andTransactionContextcarries the ambient transaction viaAsyncLocalStorage.@modularityjs/database-{typeorm,prisma}— the driver bindsTransactionRunnerand proxies itsDataSource/ Prisma client, so repositories captured once in the constructor transparently route each call through the ambient transaction — no per-methodEntityManagerplumbing.@modularityjs/database-plugins— the@Transactional(options?)method decorator. The class must be a registered provider of some module for the wrap to apply, and the app must wireDatabasePluginsModule.
import { DatabaseModule } from '@modularityjs/database';
import {
DatabasePluginsModule,
Transactional,
} from '@modularityjs/database-plugins';
import {
DatabaseEntitiesPool,
DatabaseTypeormModule,
} from '@modularityjs/database-typeorm';
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
import { PluginsModule } from '@modularityjs/plugins';
import {
Column,
DataSource,
Entity,
PrimaryGeneratedColumn,
type Repository,
} from 'typeorm';
@Entity()
class LedgerEntry {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('text')
label!: string;
}
@Injectable()
class LedgerService {
private readonly entries: Repository<LedgerEntry>;
constructor(@Inject(DataSource) dataSource: DataSource) {
// Captured once — the proxied DataSource routes every call through
// the ambient transaction, so the same repository works in and out
// of @Transactional methods.
this.entries = dataSource.getRepository(LedgerEntry);
}
@Transactional()
async transfer(from: string, to: string): Promise<void> {
await this.entries.save({ label: `debit:${from}` });
// A throw here rolls BOTH writes back.
await this.entries.save({ label: `credit:${to}` });
}
@Transactional()
async transferAndAudit(from: string, to: string): Promise<void> {
await this.transfer(from, to); // nested REQUIRED — joins this transaction
await this.audit(`transfer ${from} -> ${to}`);
}
@Transactional({ propagation: 'REQUIRES_NEW' })
async audit(message: string): Promise<void> {
// Own transaction: this commits even if the caller rolls back later.
await this.entries.save({ label: `audit:${message}` });
}
}
@Module({
name: 'ledger',
imports: [DatabaseModule, DatabaseTypeormModule, DatabasePluginsModule],
providers: [LedgerService],
pools: [{ pool: DatabaseEntitiesPool, key: 'ledger', useValue: LedgerEntry }],
})
class AppModule {}
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
PluginsModule,
DatabaseModule,
DatabaseTypeormModule.forRoot({ type: 'sqljs', synchronize: true }),
DatabasePluginsModule,
AppModule,
],
});Nested @Transactional joins by default. REQUIRED (the default) participates in an ambient transaction when one exists, so a decorated method calling another decorated method is one atomic unit. { propagation: 'REQUIRES_NEW' } always opens a fresh transaction on its own connection — it can deadlock against rows the suspended outer transaction holds locks on, so keep REQUIRES_NEW bodies small and off the outer transaction's rows.
Missing driver fails boot, not the first call. DatabasePluginsModule validates in onInit that some database driver bound TransactionRunner whenever any provider uses @Transactional — an app that decorates a method without wiring DatabaseTypeormModule or DatabasePrismaModule refuses to start with a clear message.
Options. isolation sets the isolation level (database default when unset); timeoutMs maps to Prisma's interactive-transaction timeout and has no effect on TypeORM (documented); order (default 400 — innermost) positions the wrap in the interceptor chain, so a stacked @Retryable (200) opens a fresh transaction per attempt instead of retrying inside a poisoned one.