Skip to content

Transactions Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the transactions-wiring recipe 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/pluginsPluginsModule, the method-interception seam @Transactional rides on.
  • @modularityjs/database — the contract: TransactionRunner.run(options, fn) implements the portable propagation semantics (REQUIRED joins an ambient transaction, REQUIRES_NEW always opens a fresh one; commit on return, roll back on throw) and TransactionContext carries the ambient transaction via AsyncLocalStorage.
  • @modularityjs/database-{typeorm,prisma} — the driver binds TransactionRunner and proxies its DataSource / Prisma client, so repositories captured once in the constructor transparently route each call through the ambient transaction — no per-method EntityManager plumbing.
  • @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 wire DatabasePluginsModule.
typescript
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.