Database
@modularityjs/database is the abstract contract; concrete drivers (@modularityjs/database-typeorm, @modularityjs/database-prisma) implement it. The contract carries only universal primitives — connection, migration runner, migration generator. Pool-based discovery and ORM-specific helpers live with the driver that needs them.
Contract
Configuration
@Injectable()
class DatabaseConfig {
migrationsRun = false; // Auto-run pending migrations on boot (single-node only)
}DatabaseConfig carries only what's universal across drivers. Driver-specific fields like migrationsDir, connection strings, or schema paths live on the driver's own config (DatabaseTypeormConfig, DatabasePrismaConfig).
Abstract Services
abstract class DatabaseConnection {
abstract isConnected(): boolean;
}
abstract class MigrationRunner {
abstract run(): Promise<void>;
abstract pending(): Promise<MigrationInfo[]>;
abstract executed(): Promise<MigrationInfo[]>;
abstract list(): Promise<MigrationStatus>;
}
abstract class ReversibleMigrationRunner extends MigrationRunner {
abstract rollback(): Promise<void>;
}
abstract class MigrationGenerator {
abstract generate(name: string): Promise<GeneratedMigration | undefined>;
}MigrationRunner is the universal subset. Every driver also binds ReversibleMigrationRunner, so consumers @Inject it rather than probing for it — rollback support is a property of the ORM, not of the deployment, and a capability nobody can detect is a capability nobody uses correctly. A driver whose migrations are one-way by design (Prisma) implements rollback() by throwing a StateException explaining why. This is the same treatment StorageService.createSignedUrl gets in the memory and local drivers.
Prefer MigrationRunner.list() over calling pending() and executed() separately. The sql.js TypeORM driver shares one QueryRunner per DataSource, so two back-to-back migration queries collide; list() opens a single transactional query runner internally and returns both halves of the status.
GeneratedMigration carries the path the driver wrote to:
interface GeneratedMigration {
readonly filename: string;
readonly content: string;
readonly path: string;
}Drivers
TypeORM (@modularityjs/database-typeorm)
Manages a TypeORM DataSource (bound as a transaction-aware proxy — see Transactions), provides concrete implementations of all four contract services, and binds ReversibleMigrationRunner.
import { DatabaseModule } from '@modularityjs/database';
import { DatabaseTypeormModule } from '@modularityjs/database-typeorm';
const modules = [
DatabaseModule.forRoot({
migrationsRun: true, // Auto-run on boot (single-node only)
}),
DatabaseTypeormModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
database: 'myapp',
synchronize: false,
logging: false,
migrationsDir: 'src/migrations', // Where TypeORM-style migrations are written
}),
];Universal config (migrationsRun) goes to DatabaseModule.forRoot(). Driver-specific config (type, host, migrationsDir, etc.) goes to DatabaseTypeormModule.forRoot().
The TypeORM driver re-exports the symbols modules need to register entities and migrations:
import {
DatabaseEntitiesPool,
DatabaseMigrationsPool,
DatabaseTypeormModule,
migrationPool,
} from '@modularityjs/database-typeorm';These symbols are owned by @modularityjs/database-typeorm (TypeORM's class-with-decorators model isn't universal — Prisma's schema is a file, not a runtime object). Apps that swap drivers in ways that don't affect entity registration leave these imports unchanged.
Registering entities
import {
DatabaseEntitiesPool,
DatabaseTypeormModule,
} from '@modularityjs/database-typeorm';
import { Module } from '@modularityjs/modularity';
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
class User {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
}
@Module({
name: 'users',
imports: [DatabaseTypeormModule],
pools: [{ pool: DatabaseEntitiesPool, key: 'user', useValue: User }],
})
class UsersModule {}The imports declares DatabaseTypeormModule (the pool's owner), not DatabaseModule — the pool is driver-specific.
Injecting repositories
There is no per-entity DI token — inject the DataSource and call getRepository(Entity), typically captured in the constructor:
import { Inject, Injectable } from '@modularityjs/di';
import { DataSource, type Repository } from 'typeorm';
@Injectable()
class UserService {
private readonly users: Repository<User>;
constructor(@Inject(DataSource) dataSource: DataSource) {
this.users = dataSource.getRepository(User);
}
findAll(): Promise<User[]> {
return this.users.find();
}
}Capturing the repository in the constructor is safe even under @Transactional — the bound DataSource is a transaction-aware proxy, so the repository routes through the ambient transaction per call (see Transactions).
Registering migrations
import {
DatabaseTypeormModule,
migrationPool,
} from '@modularityjs/database-typeorm';
import { Module } from '@modularityjs/modularity';
import * as migrations from './migrations/index.js';
@Module({
name: 'app',
imports: [DatabaseTypeormModule],
pools: [...migrationPool(migrations)],
})
class AppModule {}The barrel migrations/index.ts is auto-maintained by database:migration:generate.
Prisma (@modularityjs/database-prisma)
Wraps a user-supplied PrismaClient, exposes it via the PrismaClientToken DI token, and delegates migration operations to the Prisma CLI via subprocess. Binds ReversibleMigrationRunner alongside MigrationRunner, but its rollback() throws: Prisma migrations are one-way by design, so database:migration:rollback fails with an explanatory message telling you to write a corrective forward migration instead.
The examples below assume Prisma 7+, where PrismaClient is constructed with a driver adapter and the datasource URL lives in prisma.config.ts.
Schema (prisma/schema.prisma)
datasource db {
provider = "postgresql"
}
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
model Todo {
id String @id @default(uuid())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}The output field is mandatory — Prisma 7 no longer generates the client into node_modules. Imports resolve from the generated path (e.g. ./generated/prisma/client).
Prisma config (prisma.config.ts)
Datasource URLs moved out of schema.prisma and into prisma.config.ts in Prisma 7. The config also re-exposes the schema path so the CLI can find it.
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: process.env.DATABASE_URL,
},
});import 'dotenv/config' is needed because Prisma 7 disabled the CLI's automatic .env loading. Without it, pnpm exec prisma migrate dev and similar commands won't see DATABASE_URL.
Bootstrap
import { DatabaseModule } from '@modularityjs/database';
import { DatabasePrismaModule } from '@modularityjs/database-prisma';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.js';
const modules = [
DatabaseModule.forRoot({ migrationsRun: true }),
DatabasePrismaModule.forRoot({
schemaPath: 'prisma/schema.prisma',
clientFactory: () => {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is not set');
}
return new PrismaClient({
adapter: new PrismaPg({ connectionString: url }),
});
},
}),
];clientFactory is required — Prisma's generated client is owned by the app (it's generated from the app's schema). The driver wraps whatever the factory returns and never imports @prisma/client itself, so swapping driver adapters (Postgres / Neon / D1 / etc.) only changes app-level code.
Injecting the client
import { PrismaClientToken } from '@modularityjs/database-prisma';
import { Inject, Injectable } from '@modularityjs/di';
import type { PrismaClient } from '../generated/prisma/client.js';
@Injectable()
class TodoService {
constructor(
@Inject(PrismaClientToken) private readonly prisma: PrismaClient,
) {}
list() {
return this.prisma.todo.findMany({ orderBy: { createdAt: 'desc' } });
}
}There are no entity classes and no repository objects — Prisma exposes one client, and it's queried directly. Models live in schema.prisma, not in TypeScript decorators.
Build-step requirement
Prisma's generated client must exist before TypeScript can typecheck the app. Add prebuild, pretypecheck, prelint scripts that run prisma generate before each task — see apps/demo-prisma/package.json for a reference setup. Prisma 7 no longer registers an automatic postinstall generate hook, so wiring this explicitly is required.
You'll typically also want src/generated/ in .gitignore and ESLint/Prettier ignore lists, since the generator overwrites it on every run.
Running migrations
# First time: create the initial migration
pnpm exec prisma migrate dev --name init
# Subsequent: app auto-applies on boot if migrationsRun=true,
# or run as a deploy step:
pnpm exec prisma migrate deployMigrationRunner.run() shells out to prisma migrate deploy --schema <schemaPath>. MigrationGenerator.generate(name) shells out to prisma migrate dev --create-only --name <name>. The CLI subprocess inherits the parent's environment, so DATABASE_URL set on the host process propagates through; prisma.config.ts is auto-discovered from the working directory.
CLI commands
@modularityjs/database-cli is driver-agnostic — every command injects only the abstract contract.
| Command | Description |
|---|---|
database:status | Show database connection status (DatabaseConnection.isConnected()) |
database:migration:generate <name> | Generate a migration from schema changes (driver writes to its configured location) |
database:migration:run | Run all pending migrations |
database:migration:rollback | Rollback the last executed migration; throws on a one-way driver (Prisma) explaining the constraint |
database:migration:list | List all migrations with their status |
database:seed | Run all registered seeders in order (--only <names...> to filter, --force to bypass the production guard) |
database:seed:list | List registered seeders in run order |
import { DatabaseCliModule } from '@modularityjs/database-cli';
const modules = [
DatabaseModule,
DatabaseTypeormModule.forRoot({/* ... */}), // or DatabasePrismaModule
DatabaseCliModule,
];Transactions
@Transactional from @modularityjs/database-plugins wraps a method in a database transaction: commit on return, roll back on throw. It's built on the plugin system's method interceptors (see Plugins — Method-interceptor decorators), so the class must be a registered provider of some module for the wrap to apply.
import { Transactional } from '@modularityjs/database-plugins';
import { Inject, Injectable } from '@modularityjs/di';
import { DataSource, type Repository } from 'typeorm';
@Injectable()
class TransferService {
private readonly accounts: Repository<Account>;
constructor(@Inject(DataSource) dataSource: DataSource) {
this.accounts = dataSource.getRepository(Account);
}
@Transactional()
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
await this.accounts.decrement({ id: fromId }, 'balance', amount);
// A throw anywhere below rolls back the decrement above.
await this.accounts.increment({ id: toId }, 'balance', amount);
}
}Wiring
import { DatabaseModule } from '@modularityjs/database';
import { DatabasePluginsModule } from '@modularityjs/database-plugins';
import { DatabaseTypeormModule } from '@modularityjs/database-typeorm';
import { PluginsModule } from '@modularityjs/plugins';
const modules = [
PluginsModule,
DatabaseModule,
DatabaseTypeormModule.forRoot({/* ... */}), // or DatabasePrismaModule
DatabasePluginsModule,
];Misconfiguration fails at boot, not at the first decorated call: a @Transactional method without DatabasePluginsModule fails boot loudly (unwired interceptor source), and DatabasePluginsModule validates in onInit that a database driver binds TransactionRunner — without one, boot fails with a clear message telling you to wire DatabaseTypeormModule or DatabasePrismaModule.
Options
@Transactional({
propagation: 'REQUIRES_NEW',
isolation: 'SERIALIZABLE',
timeoutMs: 10_000,
})| Option | Values | Description |
|---|---|---|
propagation | 'REQUIRED' (default) | 'REQUIRES_NEW' | REQUIRED joins the ambient transaction when one is active; REQUIRES_NEW always opens a fresh one. |
isolation | 'READ UNCOMMITTED' | 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE' | Passed through to the driver (TypeORM's isolation strings verbatim; the Prisma driver maps them). Database default when unset. |
timeoutMs | number | Upper bound on transaction duration. Prisma: interactive-transaction timeout (config default transactionTimeoutMs, 5s). TypeORM: no effect. |
order | number | Chain position among stacked method-interceptor decorators; lower = outermost. Default 400 (innermost — a stacked @Retryable retries fresh transactions). |
The ambient transaction
The active transaction lives in TransactionContext — a dedicated AsyncLocalStorage, deliberately not the ScopeService chain (whose runInScope replaces the store and would clobber tenancy/config scoping; separate storages nest independently, so a transaction inside a tenant-scoped request keeps both contexts). Nested @Transactional calls join the ambient transaction by default (REQUIRED) or open their own with { propagation: 'REQUIRES_NEW' }.
The bound DataSource (TypeORM) and the client behind PrismaClientToken (Prisma) are transaction-aware proxies: TypeORM's manager, getRepository, createQueryBuilder, and query, and Prisma's model delegates (client.user.findMany(...)), resolve against the active transaction handle per call. Repositories and delegates captured in a constructor — the injection pattern above — participate in whatever transaction is active at each method call, with zero changes.
For raw query builders and library code, TransactionContext.getHandle() is the explicit escape hatch — it returns the driver's transaction object (a TypeORM EntityManager, a Prisma interactive-transaction client), or undefined outside a transaction:
import { TransactionContext } from '@modularityjs/database';
import type { EntityManager } from 'typeorm';
@Injectable()
class ReportService {
constructor(
@Inject(TransactionContext) private readonly context: TransactionContext,
@Inject(DataSource) private readonly dataSource: DataSource,
) {}
async count(): Promise<number> {
const manager = this.context.getHandle<EntityManager>();
const [row] = await (manager ?? this.dataSource.manager).query(
'SELECT COUNT(*) AS n FROM account',
);
return Number(row.n);
}
}REQUIRES_NEW caveat
The inner transaction runs on its own connection and can deadlock against rows the suspended outer transaction holds locks on — this applies to all drivers. Keep REQUIRES_NEW work disjoint from the outer transaction's row set (audit logs, outbox writes).
A nested @Transactional that joins an ambient transaction (the default REQUIRED) cannot change the isolation level — isolation is fixed when the transaction opens. Requesting a different isolation on a joining call fails loudly with a StateException (rather than silently running at the weaker ambient level); use REQUIRES_NEW if the inner work genuinely needs its own isolation.
Concurrency caveat
Every query inside one REQUIRED transaction resolves to the same connection (a single TypeORM QueryRunner / Prisma interactive-transaction client), which is not safe for concurrent queries. Promise.all([repoA.find(), repoB.find()]) inside a @Transactional method issues concurrent statements on one connection and produces interleaved/corrupted results or driver errors. Await queries sequentially within a transaction; for genuine parallelism, open a REQUIRES_NEW transaction (a separate connection) per parallel branch.
Streaming caveat
A @Transactional method must fully materialize its result before returning. The transaction commits when the returned promise resolves — so if the method returns a lazily-consumed async iterator or a driver stream/cursor, rows are pulled after the method returns, after commit and after the connection is released, surfacing as "connection released" / "transaction already committed" errors or reads outside the transaction. Collect streamed rows into an array inside the decorated method.
Seeding
Seeders are ordinary injectable app classes contributed to DatabaseSeedersPool. They constructor-inject whatever persistence handle they use (a TypeORM DataSource, the Prisma client token, or a domain service), so the runner stays driver-agnostic — neither database driver participates. run() must be idempotent (upsert, don't blind-insert): seeds are re-runnable by design, there is no execution-tracking table.
Seeding is non-atomic: seeders run sequentially, each committing on its own. A seeder that throws mid-run aborts the rest, leaving the ones that already ran committed — there is no surrounding transaction. Recovery relies entirely on the idempotency contract above: fix the failing seeder and re-run; the ones that already applied must no-op.
import {
DatabaseModule,
DatabaseSeedersPool,
Seeder,
} from '@modularityjs/database';
@Injectable()
export class UsersSeeder extends Seeder {
readonly name = 'users';
readonly order = 0; // lower runs first; ties preserve module load order
constructor(@Inject(DataSource) private readonly dataSource: DataSource) {
super();
}
async run(): Promise<void> {
await this.dataSource
.getRepository(User)
.upsert({ email: 'admin@example.com', name: 'Admin' }, ['email']);
}
}
@Module({
name: 'users-seed',
imports: [DatabaseModule, DatabaseTypeormModule],
providers: [UsersSeeder],
pools: [{ pool: DatabaseSeedersPool, key: 'users', useClass: UsersSeeder }],
})
export class UsersSeedModule {}Run with pnpm modularity cli database:seed. SeedRunner refuses to run when DatabaseConfig.seedingEnabled is false — the default when NODE_ENV=production — unless --force is passed; enable it deliberately for staging via DatabaseModule.forRoot({ seedingEnabled: true }). Scaffold a seeder module with pnpm modularity generate seeder <name>.
Programmatic access
import {
DatabaseConnection,
MigrationRunner,
ReversibleMigrationRunner,
} from '@modularityjs/database';
import { Inject, Injectable } from '@modularityjs/di';
@Injectable()
class DatabaseHealthService {
constructor(
@Inject(DatabaseConnection) private readonly connection: DatabaseConnection,
) {}
isHealthy(): boolean {
return this.connection.isConnected();
}
}
@Injectable()
class MigrationService {
constructor(
@Inject(MigrationRunner) private readonly runner: MigrationRunner,
// Bound by every driver — a one-way driver throws from rollback() rather
// than leaving the token unbound, so there is nothing to probe for.
@Inject(ReversibleMigrationRunner)
private readonly reversible: ReversibleMigrationRunner,
) {}
async runPending(): Promise<void> {
const { pending } = await this.runner.list();
if (pending.length > 0) {
await this.runner.run();
}
}
async undoLast(): Promise<void> {
// Throws a StateException on a one-way driver (Prisma); let it propagate.
await this.reversible.rollback();
}
}Driver portability
Apps swap drivers by changing two things: the driver module in bootstrap.ts, and the entity/schema definitions (TypeORM classes vs. Prisma schema). Module code that uses @Inject(DatabaseConnection) or @Inject(MigrationRunner) is portable across drivers.
App code that injects the TypeORM DataSource is TypeORM-specific. App code that uses @Inject(PrismaClientToken) is Prisma-specific. That's an honest reflection of the underlying ORMs' different ergonomics — the framework doesn't paper over the difference with a fake unifying abstraction.