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. Drivers whose migrations are reversible (TypeORM) bind ReversibleMigrationRunner (which also satisfies MigrationRunner). Drivers whose migrations are one-way by design (Prisma) bind only the base MigrationRunner. The CLI's database:migration:rollback command optional-injects ReversibleMigrationRunner and prints a clear error when no driver provides it.
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, auto-binds repositories for each entity in the pool, 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,
repositoryOf,
} 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
import { repositoryOf } from '@modularityjs/database-typeorm';
import { Inject, Injectable } from '@modularityjs/di';
import type { Repository } from 'typeorm';
@Injectable()
class UserService {
constructor(
@Inject(repositoryOf(User)) private readonly repo: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.repo.find();
}
}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 only the base MigrationRunner — Prisma migrations are one-way by design, so database:migration:rollback will fail cleanly with an explanatory message.
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 repositoryOf() analog — 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; fails cleanly if the active driver doesn't support rollback (Prisma) |
database:migration:list | List all migrations with their status |
import { DatabaseCliModule } from '@modularityjs/database-cli';
const modules = [
DatabaseModule,
DatabaseTypeormModule.forRoot({
/* ... */
}), // or DatabasePrismaModule
DatabaseCliModule,
];Programmatic access
import {
DatabaseConnection,
MigrationRunner,
ReversibleMigrationRunner,
} from '@modularityjs/database';
import { Inject, InjectOptional, 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,
// Only bound by drivers whose migrations are reversible (TypeORM, not Prisma)
@InjectOptional(ReversibleMigrationRunner)
private readonly reversible: ReversibleMigrationRunner | undefined,
) {}
async runPending(): Promise<void> {
const { pending } = await this.runner.list();
if (pending.length > 0) {
await this.runner.run();
}
}
async undoLast(): Promise<void> {
if (!this.reversible) {
throw new Error('Active driver does not support rollback');
}
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 uses @Inject(repositoryOf(User)) 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.