Skip to content

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

typescript
@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

typescript
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:

typescript
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.

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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)

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.

typescript
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

typescript
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

typescript
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

bash
# 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 deploy

MigrationRunner.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.

CommandDescription
database:statusShow database connection status (DatabaseConnection.isConnected())
database:migration:generate <name>Generate a migration from schema changes (driver writes to its configured location)
database:migration:runRun all pending migrations
database:migration:rollbackRollback the last executed migration; fails cleanly if the active driver doesn't support rollback (Prisma)
database:migration:listList all migrations with their status
typescript
import { DatabaseCliModule } from '@modularityjs/database-cli';

const modules = [
  DatabaseModule,
  DatabaseTypeormModule.forRoot({
    /* ... */
  }), // or DatabasePrismaModule
  DatabaseCliModule,
];

Programmatic access

typescript
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.