Prisma, Drizzle, MikroORM & Sequelize
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), Node.js 24 LTS (Node.js 20.19+ or 22.12+ run Nest 12), TypeScript 6 (6.0.3), Express 5 and Fastify 5, and was checked on 2026-10-02. The data libraries it uses are Prisma ORM 7.10.0 with @prisma/adapter-better-sqlite3, Drizzle ORM 1.0.0-rc.4 with @nestjs/drizzle 0.0.1 and drizzle-kit 1.0.0-rc.4, MikroORM 7.2.3 with @mikro-orm/nestjs 7.1.0, and Sequelize 6.37.8 with sequelize-typescript 2.1.6 and @nestjs/sequelize 12.0.0. The examples use a Nest 12 workspace (ESM, module: nodenext, Vitest and supertest) with a SQLite file through better-sqlite3 or sqlite3, and type-check with tsc --noEmit. They are not exercised against PostgreSQL, MySQL or SQL Server, so connection options for those servers are described from the official documentation only.
Data Overview & TypeORM introduced Bookshelf and its TypeORM repository. This page rebuilds the same slice with each of the other four SQL integrations the official documentation covers, so that you can compare them on equal terms: a Book belongs to an Author, the service lists books with their author, and one operation creates an author and a book in a single transaction that must roll back as a whole if the book cannot be saved. The four integrations differ in more than syntax. Prisma generates a client from a schema file and has no Nest package at all. Drizzle declares tables as plain functions and has a pre-1.0 Nest package. MikroORM is a Data Mapper with a unit of work and needs a per-request context. Sequelize is an Active Record ORM whose models are classes. Pick by how you want to model data and test it, not by popularity; the comparison table at the end summarises the trade-offs. For documents instead of rows see MongoDB with Mongoose, and for caching the results of these queries see Caching.
The shared Bookshelf slice
Every ORM below implements the same small contract so that the HTTP layer never changes. The request body is a plain interface, and a single controller talks to whichever service the module provides under an injection token. The controller maps any persistence failure to a 409 Conflict without leaking driver messages to the client.
src/common)export interface CreateBook {
title: string;
isbn: string;
authorName: string;
}
import { Body, ConflictException, Controller, Get, Inject, Post } from '@nestjs/common';
import type { CreateBook } from './create-book.dto.js';
export const BOOKS_SERVICE = Symbol('BOOKS_SERVICE');
export interface BooksPort {
findAll(): Promise<unknown[]>;
createWithAuthor(dto: CreateBook): Promise<unknown>;
}
@Controller('books')
export class BooksController {
constructor(@Inject(BOOKS_SERVICE) private readonly books: BooksPort) {}
@Get()
findAll() {
return this.books.findAll();
}
@Post()
async create(@Body() dto: CreateBook) {
try {
return await this.books.createWithAuthor(dto);
} catch {
throw new ConflictException('Could not save the book');
}
}
}
In a real application the body would be a validated DTO class (see Pipes & Validation); an interface is used here only to keep the focus on persistence, and because TypeScript interfaces are erased, ValidationPipe cannot check them. The model is smaller than the TypeORM page’s: Author (id, unique name) and Book (id, title, unique isbn, an author reference). Each module registers its service twice, useExisting: BooksService under BOOKS_SERVICE, so the controller depends on the token only.
Source: NestJS: Custom providers
The data layer is a provider like any other. Its connection is opened while modules are initialised and closed on application shutdown, which is why the lifecycle hooks of Lifecycle Events & Shutdown matter for every integration here. Express versus Fastify makes no difference to any code on this page.
Prisma
Prisma is an ORM whose model lives in a schema file, schema.prisma. A CLI generates a type-safe client from that schema, and Prisma Migrate generates SQL migrations from the same file. There is no @nestjs/prisma package: you wrap the generated PrismaClient in a provider and inject it. This section follows the official page, which uses Prisma ORM 7 with the SQL databases. MongoDB projects stay on Prisma ORM 6 for now.
Installation, and a trap with the npm tag
Install the CLI and dotenv as development dependencies and the client plus a driver adapter as runtime dependencies. The adapter package for SQLite also installs the better-sqlite3 driver.
npm install prisma@7 dotenv --save-dev
npm install @prisma/client@7 @prisma/adapter-better-sqlite3
Source: NestJS: Prisma
The @7 is deliberate. On 2026-10-02 the latest tag of the prisma package on npm points to a Prisma 8 release candidate (8.0.0-rc.19), a different CLI that does not provide the prisma migrate and prisma generate commands used here. npm install prisma without a version therefore gives you a tool that does not match the official Nest page. Keep prisma and @prisma/client on the same major version, and check npm view prisma dist-tags before you pin.
Prisma 7 also requires a driver adapter (or a Prisma Accelerate URL) to create a client: new PrismaClient() without one is no longer enough. Other databases use @prisma/adapter-pg (PostgreSQL), @prisma/adapter-mariadb (MySQL and MariaDB) or @prisma/adapter-mssql (SQL Server), each of which bundles its driver.
Generator output, CommonJS or ESM
npx prisma init --datasource-provider sqlite --output ../src/generated/prisma creates prisma/schema.prisma, a Prisma config file in the project root and a .env file with DATABASE_URL, and adds .env and the generated directory to .gitignore. (The schema below uses the output ../src/prisma/generated instead.) With Prisma 7.10 the config file is named prisma7.config.ts; earlier releases name it prisma.config.ts, which Prisma still recognises. The output path in the generator block is relative to the schema file, and the --output option puts the client inside src, so it is compiled with the rest of your application. Because the generated client is not committed, run npx prisma generate before the build in a fresh checkout or in CI.
prisma/schema.prismagenerator client {
provider = "prisma-client"
output = "../src/prisma/generated"
}
datasource db {
provider = "sqlite"
}
model Author {
id Int @id @default(autoincrement())
name String @unique
books Book[]
}
model Book {
id Int @id @default(autoincrement())
title String
isbn String @unique
author Author @relation(fields: [authorId], references: [id])
authorId Int
}
Source: NestJS: Prisma
prisma7.config.tsimport "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
The connection URL is not part of the schema; the config file reads it from DATABASE_URL. The Prisma CLI loads .env through the dotenv/config import at the top of that file, but your Nest application does not load .env by itself, so register ConfigModule.forRoot({ isGlobal: true }) and read the URL with ConfigService (see Configuration).
The module format of the generated client is inferred from your tsconfig.json. A Nest project that uses ES modules (the default for new projects) gets an ES module client and needs nothing more. For a CommonJS project, set moduleFormat in the generator block:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
Source: NestJS: Prisma
The difference is real, not cosmetic: the generated ESM entry file (client.ts) uses import.meta, which does not exist in CommonJS. The ESM output contains import.meta, and regenerating with moduleFormat = "cjs" produces a client without it. Prisma releases before 7.10 generate an ES module whenever tsconfig.json sets module to nodenext, even in a CommonJS project, so set moduleFormat explicitly instead of relying on inference. Import the generated client with a .js extension in an ESM project (./generated/client.js), like any other relative import.
Migrations
prisma migrate dev --name init creates the SQL migration in prisma/migrations and applies it, creating the SQLite file if needed. It does not run prisma generate; run that yourself whenever the schema changes. In deployment use prisma migrate deploy, which applies pending migrations without generating new ones; the end-to-end test below calls it to build its database. Migrations run outside Nest, so they cannot use dependency injection.
npx prisma migrate dev --name init
npx prisma generate
npx prisma migrate deploy
Source: NestJS: Prisma
PrismaService and PrismaModule
PrismaService extends the generated PrismaClient, so the service has the whole client API, and passes the driver adapter to the parent constructor. It reads the URL before calling super(); getOrThrow() stops the application at startup when the variable is missing.
PrismaService, with onModuleInit and onModuleDestroyimport { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from './generated/client.js';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(configService: ConfigService) {
const adapter = new PrismaBetterSqlite3({
url: configService.getOrThrow<string>('DATABASE_URL'),
});
super({ adapter });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
Source: NestJS: Prisma
Prisma Client connects lazily, on the first query. Calling $connect() in onModuleInit() connects during bootstrap instead, so a misconfigured URL fails at startup rather than on the first request. $disconnect() in onModuleDestroy() closes the connection (and disposes the driver adapter) when app.close() is called. Nest also calls onModuleDestroy() on SIGTERM if you enable shutdown hooks with app.enableShutdownHooks() in main.ts. Prisma 7 no longer supports the $on('beforeExit', …) event that older Nest and Prisma guides used to close the app; use the Nest lifecycle hooks as shown.
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
Source: NestJS: Prisma
Providers are singletons by default, so every module that imports PrismaModule shares one PrismaService and one connection. Decorating the module with @Global() and importing it once in the root module is possible; see Modules for the trade-offs.
Querying and transactions
Queries go through the model properties of the injected client. The generated types (Prisma.BookWhereInput and so on) are compile-time only, so they cannot validate request bodies; use DTO classes for that.
Prisma offers three ways to run queries in a transaction. Nested writes (creating a record together with related records in one query) are transactional automatically. A sequential transaction passes an array of queries to $transaction(); they run in order in a single transaction and resolve to an array of results. An interactive transaction passes an async function; Prisma commits when it resolves and rolls back when it throws. Inside it, every query must use the transaction client (tx); queries sent through this.prisma run outside the transaction.
BooksService with an interactive and a sequential transactionimport { Injectable } from '@nestjs/common';
import { CreateBook } from '../common/create-book.dto.js';
import { PrismaService } from './prisma.service.js';
@Injectable()
export class BooksService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.book.findMany({
include: { author: true },
orderBy: { id: 'asc' },
});
}
// Interactive transaction: everything through `tx`, or nothing is saved.
createWithAuthor(dto: CreateBook) {
return this.prisma.$transaction(async (tx) => {
const author = await tx.author.upsert({
where: { name: dto.authorName },
update: {},
create: { name: dto.authorName },
});
return tx.book.create({
data: { title: dto.title, isbn: dto.isbn, authorId: author.id },
include: { author: true },
});
});
}
// Sequential transaction: an array of queries run in one transaction.
async page(skip: number, take: number) {
const [books, total] = await this.prisma.$transaction([
this.prisma.book.findMany({ skip, take, orderBy: { id: 'asc' } }),
this.prisma.book.count(),
]);
return { books, total };
}
}
Source: NestJS: Prisma
createWithAuthor() upserts the author and creates the book through tx. If the ISBN already exists, the insert fails, the callback throws and the author created moments earlier is rolled back; the test below proves it. Both forms of $transaction() accept an options object as the second argument (maxWait, timeout, isolationLevel). To avoid passing tx through every method, the official page points to the nestjs-cls transactional plugin with its Prisma adapter, which keeps the transaction in async local storage. For SQL-level semantics (isolation levels, locks) see Transaction Control.
import { Module } from '@nestjs/common';
import { BOOKS_SERVICE, BooksController } from '../common/books.controller.js';
import { PrismaModule } from './prisma.module.js';
import { BooksService } from './books.service.js';
@Module({
imports: [PrismaModule],
controllers: [BooksController],
providers: [BooksService, { provide: BOOKS_SERVICE, useExisting: BooksService }],
})
export class BooksModule {}
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BooksModule } from './books.module.js';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), BooksModule],
})
export class AppModule {}
Source: NestJS: Prisma
Testing
Because PrismaService is an ordinary class provider, a unit test replaces it with a mock using the class as the injection token. The mock replaces the service entirely, so onModuleInit() never runs and no connection opens. For tests that import PrismaModule, use overrideProvider(PrismaService) instead. The end-to-end test below instead runs the real client against a temporary SQLite file created with prisma migrate deploy.
test/prisma.e2e-spec.ts)it('rolls the author back when the book insert fails', async () => {
await request(app.getHttpServer())
.post('/books')
.send({ title: 'Copy', isbn: '978-0441172719', authorName: 'New Author' })
.expect(409);
const prisma = app.get(PrismaService);
expect(await prisma.author.findUnique({ where: { name: 'New Author' } })).toBeNull();
});
it('can be unit tested with a mock PrismaService', async () => {
const prismaMock = { book: { findMany: vi.fn().mockResolvedValue([]) } };
const moduleRef = await Test.createTestingModule({
providers: [BooksService, { provide: PrismaService, useValue: prismaMock }],
}).compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
expect(prismaMock.book.findMany).toHaveBeenCalledWith({ include: { author: true }, orderBy: { id: 'asc' } });
});
Source: NestJS: Prisma
Drizzle
Drizzle ORM declares the schema in TypeScript with plain functions, writes queries with a SQL-like builder (or a relational query API for nested reads) and infers every result type from the schema. There are no entity classes, decorators or generation steps. Nest has a first-party integration, @nestjs/drizzle, which is pre-1.0: version 0.0.1 at the time of writing, so its API can change in minor releases. Drizzle ORM itself is also on a release-candidate line for v1 (1.0.0-rc.4); the official page installs drizzle-orm@rc and drizzle-kit@rc, and says @nestjs/drizzle also supports Drizzle v0.35 and later, where only relations and relational queries differ. The latest tag of drizzle-orm is still 0.45.3 on 2026-10-02, so a plain npm install drizzle-orm gives you v0 and the relations API shown below will not work.
npm install @nestjs/drizzle drizzle-orm@rc better-sqlite3
npm install --save-dev drizzle-kit@rc @types/better-sqlite3
Source: NestJS: Drizzle
The official page uses PostgreSQL with node-postgres; this page uses SQLite so that it can run anywhere. The steps are the same for every driver: import drizzle() from the entry point that matches your driver (drizzle-orm/node-postgres, drizzle-orm/mysql2, drizzle-orm/better-sqlite3, and so on).
Schema
Tables are exported from a schema file. The $inferSelect and $inferInsert helpers derive row types from the table, so there is no model class to keep in sync.
src/drizzle/db/schema.tsimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const authors = sqliteTable('authors', {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(),
});
export const books = sqliteTable('books', {
id: integer().primaryKey({ autoIncrement: true }),
title: text().notNull(),
isbn: text().notNull().unique(),
authorId: integer('author_id')
.notNull()
.references(() => authors.id),
});
export type Author = typeof authors.$inferSelect;
export type Book = typeof books.$inferSelect;
export type NewBook = typeof books.$inferInsert;
Source: NestJS: Drizzle
Relations
Relations drive relational queries (db.query); they are declared for the whole schema at once with defineRelations() (the Drizzle v1 API). Foreign key constraints themselves come from references() in the table definition, as the authorId column above shows. The database type then depends on the relations, so declare it once and reuse it.
src/drizzle/db/relations.ts and database.tsimport { defineRelations } from 'drizzle-orm';
import * as schema from './schema.js';
export const relations = defineRelations(schema, (r) => ({
authors: {
books: r.many.books(),
},
books: {
author: r.one.authors({
from: r.books.authorId,
to: r.authors.id,
}),
},
}));
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type { relations } from './relations.js';
export type Database = BetterSQLite3Database<typeof relations>;
Source: NestJS: Drizzle
Registering the database
DrizzleModule.forRoot({ drizzle, connection }) takes the drizzle() function of your driver and a connection (a string or driver options), and calls it once for each application instance. Each application, including each one your e2e tests create, therefore gets its own connection, closed on shutdown (autoCloseConnection defaults to true). Other options, such as relations, logger or casing, are passed on to drizzle(). The options of forRoot() are evaluated when the module file is imported, so process.env values must already be set at that point. To read the connection through ConfigService, use forRootAsync(), as here, where the factory also applies pending migrations. The factory may be async, and Nest does not create providers that inject the database until it resolves.
src/drizzle/app.module.tsimport { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DrizzleModule } from '@nestjs/drizzle';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { relations } from './db/relations.js';
import { BooksModule } from './books.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DrizzleModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const db = drizzle(config.getOrThrow<string>('DATABASE_FILE'), { relations });
// Apply pending migrations at startup (see the warning about several instances).
migrate(db, { migrationsFolder: './drizzle' });
return { db };
},
}),
BooksModule,
],
})
export class AppModule {}
Source: NestJS: Drizzle
Unlike TypeORM there is no forFeature() step: the database is registered globally and any provider injects it with @InjectDrizzle(), without importing the module. Passing a database you built yourself as db (for example one created with withReplicas()) also works. The official page warns that a db instance exported from a shared file is created once, so the first application to shut down closes it and breaks e2e suites that create an application per test; create it inside a forRootAsync() factory, or set autoCloseConnection to false and close the client yourself. A database registered without a name is called default; additional databases need unique names (@InjectDrizzle('analytics')).
Queries and transactions
src/drizzle/books.service.tsimport { Injectable } from '@nestjs/common';
import { InjectDrizzle } from '@nestjs/drizzle';
import { eq } from 'drizzle-orm';
import { CreateBook } from '../common/create-book.dto.js';
import type { Database } from './db/database.js';
import { authors, books } from './db/schema.js';
@Injectable()
export class BooksService {
constructor(@InjectDrizzle() private readonly db: Database) {}
findAll() {
return this.db.query.books.findMany({ with: { author: true }, orderBy: { id: 'asc' } });
}
findByAuthor(name: string) {
return this.db
.select({ id: books.id, title: books.title })
.from(books)
.innerJoin(authors, eq(books.authorId, authors.id))
.where(eq(authors.name, name));
}
// better-sqlite3 is a synchronous driver: the transaction callback must not be
// async, and statements end with .run() / .get() / .all().
async createWithAuthor(dto: CreateBook) {
return this.db.transaction((tx) => {
tx.insert(authors).values({ name: dto.authorName }).onConflictDoNothing().run();
const author = tx.select().from(authors).where(eq(authors.name, dto.authorName)).get()!;
const book = tx
.insert(books)
.values({ title: dto.title, isbn: dto.isbn, authorId: author.id })
.returning()
.get();
return { ...book, author };
});
}
}
Source: NestJS: Drizzle
findAll() is a relational query: with: { author: true } fetches the nested author without hand-written joins. findByAuthor() uses the SQL-like builder with an explicit join. A transaction starts with db.transaction(); the callback receives tx with the same query API, commits when it returns and rolls back when it throws. Queries that belong to the transaction must run through tx.
A pitfall that the official page does not mention, because it uses node-postgres: better-sqlite3 is a synchronous driver, and its transactions cannot take an async callback. db.transaction(async (tx) ⇒ …) against better-sqlite3 throws Transaction function cannot return a promise after the first statement has already been executed, and the write is not rolled back. With a synchronous driver, write the callback without async and finish statements with .run(), .get() or .all(), as createWithAuthor() does; with asynchronous drivers (node-postgres, mysql2, libsql) use async and await as the official page shows.
If you want a repository layer, write it yourself as a provider that injects the database; Drizzle has none. Beware that repository methods query the injected database, not a transaction’s tx, so with a connection pool they run on another connection and are not part of an ongoing transaction unless you pass tx in or propagate it with nestjs-cls (which has a Drizzle adapter).
Migrations with drizzle-kit
drizzle-kit reads drizzle.config.ts, generates SQL migrations from your schema changes (drizzle-kit generate) and applies them (drizzle-kit migrate). It runs outside Nest, so migrations cannot use dependency injection; it loads .env from the directory you run it in, so DATABASE_FILE is available without ConfigModule.
drizzle.config.tsimport { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './src/drizzle/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_FILE!,
},
});
npx drizzle-kit generate --name init
npx drizzle-kit migrate
Source: NestJS: Drizzle
With drizzle-kit@rc (1.0.0-rc.4) each migration is a folder, such as drizzle/20261002155900_init/, holding migration.sql and snapshot.json; the migrate() function of the driver (used in the module above) understands this layout. Applying migrations from a forRootAsync() factory is convenient, but when several instances of the application start at the same time each one tries to apply them; in such deployments run drizzle-kit migrate as a separate release step instead.
Testing
Replace the database with a mock by providing a value under getDrizzleToken(). Drizzle’s query builder is chainable (select().from().where()), so a mock must implement every call in the chain. For anything beyond trivial queries, either hide the queries behind a repository class and mock that, or run the test against a real database file, as the end-to-end test does.
test/drizzle.e2e-spec.ts)it('rolls the transaction back when the book insert fails', async () => {
await request(app.getHttpServer())
.post('/books')
.send({ title: 'Copy', isbn: '978-0441172719', authorName: 'New Author' })
.expect(409);
const db = app.get<Database>(getDrizzleToken());
const rows = await db.select().from(authors);
expect(rows.map((a) => a.name)).toEqual(['Frank Herbert']);
});
it('can be unit tested by replacing the database token', async () => {
const mockDb = { query: { books: { findMany: async () => [] } } };
const moduleRef = await Test.createTestingModule({
providers: [BooksService, { provide: getDrizzleToken(), useValue: mockDb }],
}).compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
});
Source: NestJS: Drizzle
MikroORM
MikroORM is a TypeScript ORM built on the Data Mapper, Unit of Work and Identity Map patterns. The @mikro-orm/nestjs package is third-party and not maintained by the Nest core team (issues go to the mikro-orm/nestjs repository). The official page targets MikroORM v7 and @mikro-orm/nestjs v7; if you upgrade from v6, read the MikroORM v6 to v7 upgrade guide, because v7 changed several imports.
npm install @mikro-orm/nestjs @mikro-orm/core @mikro-orm/decorators @mikro-orm/sqlite
Source: NestJS: MikroORM
Three v7 changes catch people who copy older snippets. Decorators are no longer exported from @mikro-orm/core: Nest uses legacy (experimental) decorators, so import @Entity(), @Property() and friends from @mikro-orm/decorators/legacy. EntityManager, EntityRepository and MikroORM are imported from your driver package (here @mikro-orm/sqlite) to get the driver-specific API. And ReflectMetadataProvider is no longer the default metadata provider, so set metadataProvider: ReflectMetadataProvider explicitly to let MikroORM infer property types from the metadata TypeScript emits.
Entities
author.entity.ts and book.entity.tsimport { Collection } from '@mikro-orm/core';
import { Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
import { Book } from './book.entity.js';
@Entity()
export class Author {
@PrimaryKey()
id!: number;
@Property({ unique: true })
name!: string;
@OneToMany(() => Book, (book) => book.author)
books = new Collection<Book>(this);
}
import { type Rel } from '@mikro-orm/core';
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
import { Author } from './author.entity.js';
@Entity()
export class Book {
@PrimaryKey()
id!: number;
@Property()
title!: string;
@Property({ unique: true })
isbn!: string;
@ManyToOne(() => Author)
author!: Rel<Author>;
}
Source: NestJS: MikroORM
When two entities reference each other, wrap the relation type in Rel<T> (or Ref<T>) from @mikro-orm/core and pass the target to the relation decorator, as book.entity.ts does. Otherwise TypeScript emits decorator metadata that references the other class directly, and in an ESM project the circular import fails at startup with ReferenceError: Cannot access 'Author' before initialization. The collection side (books = new Collection<Book>(this)) needs no wrapper because it is initialised rather than annotated with the other class. We reproduced the same error with Sequelize models (see below), so this is a general rule for decorator-metadata ORMs in ESM.
Configuration
MikroOrmModule.forRoot() takes the same object as MikroORM.init(), plus Nest options: autoLoadEntities, registerRequestContext (default true), forRoutesPath, scope and contextName. Since v7, forRoot() does not accept an empty argument list. With forRootAsync(), set driver next to useFactory, not inside the object the factory returns: the module needs the driver before it calls the factory to register the driver-specific EntityManager and MikroORM tokens, and without it injecting the EntityManager from your driver package fails with a dependency resolution error.
src/mikroorm/app.module.tsimport { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy';
import { SqliteDriver } from '@mikro-orm/sqlite';
import { BooksModule } from './books.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MikroOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
driver: SqliteDriver, // next to useFactory, not inside the returned options
useFactory: (config: ConfigService) => ({
dbName: config.getOrThrow<string>('DATABASE_FILE'),
metadataProvider: ReflectMetadataProvider,
autoLoadEntities: true,
}),
}),
BooksModule,
],
})
export class AppModule {}
Source: NestJS: MikroORM
With autoLoadEntities: true, entities registered through forFeature() are added to the configuration automatically, which keeps the root module free of references to every entity and works in bundled applications where the glob-based discovery (entities, entitiesTs) cannot find files. Entities that are only referenced from another entity are not included. autoLoadEntities has no effect on the MikroORM CLI, which needs its own config listing the entities.
Repositories and transactions
Repositories are registered per module with forFeature() and injected with @InjectRepository(). They do not have persist() or flush(): write operations go through the EntityManager, which implements the Unit of Work, and em.flush() writes all pending changes in one transaction. em.transactional() gives you explicit control: the callback receives a forked EntityManager, MikroORM flushes it and commits when the callback returns, and rolls back and rethrows when it throws. A @Transactional() decorator (from @mikro-orm/decorators/legacy) does the same for a whole method; it joins an existing transaction by default, whereas a nested em.transactional() starts a savepoint.
src/mikroorm/books.service.ts and books.module.tsimport { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager, EntityRepository } from '@mikro-orm/sqlite';
import { CreateBook } from '../common/create-book.dto.js';
import { Author } from './author.entity.js';
import { Book } from './book.entity.js';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book) private readonly books: EntityRepository<Book>,
@InjectRepository(Author) private readonly authors: EntityRepository<Author>,
private readonly em: EntityManager,
) {}
findAll() {
return this.books.findAll({ populate: ['author'], orderBy: { id: 'asc' } });
}
// em.transactional() forks the EntityManager, flushes and commits when the
// callback returns, and rolls back when it throws.
createWithAuthor(dto: CreateBook) {
return this.em.transactional(async (em) => {
let author = await em.findOne(Author, { name: dto.authorName });
if (!author) {
author = em.create(Author, { name: dto.authorName });
}
return em.create(Book, { title: dto.title, isbn: dto.isbn, author });
});
}
}
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { BOOKS_SERVICE, BooksController } from '../common/books.controller.js';
import { Author } from './author.entity.js';
import { Book } from './book.entity.js';
import { BooksService } from './books.service.js';
import { StatsService } from './stats.service.js';
@Module({
imports: [MikroOrmModule.forFeature([Author, Book])],
controllers: [BooksController],
providers: [BooksService, StatsService, { provide: BOOKS_SERVICE, useExisting: BooksService }],
exports: [StatsService],
})
export class BooksModule {}
Source: NestJS: MikroORM
Request context and @CreateRequestContext
MikroORM keeps loaded entities in an identity map that must not be shared between concurrent requests, so each request needs its own fork of the EntityManager. By default MikroOrmModule registers a middleware for all routes that runs every HTTP request inside a RequestContext; the global EntityManager you inject, and every repository built on it, transparently delegates to the request’s fork. Set forRoutesPath to restrict the middleware, registerRequestContext: false to disable it, or make the EntityManager provider request-scoped (scope: Scope.REQUEST), which makes every provider that depends on it request-scoped too and carries the performance cost described in Injection Scopes.
A middleware only runs for HTTP requests. Cron jobs, queue consumers and other code outside that flow have no context, and MikroORM then refuses to use the global EntityManager for operations that depend on the identity map (find(), flush()) and throws a ValidationError. Decorate such async methods with @CreateRequestContext(), which forks the EntityManager and runs the method in a new context. The decorator looks for an orm (MikroORM) or an em (EntityManager) property on the class, so inject one under that name. Its sibling @EnsureRequestContext() creates a context only when one is not already active. In v7 @CreateRequestContext() is exported from @mikro-orm/decorators/legacy (earlier it came from @mikro-orm/core); before v6 it was called @UseRequestContext().
src/mikroorm/stats.service.tsimport { Injectable } from '@nestjs/common';
import { CreateRequestContext } from '@mikro-orm/decorators/legacy';
import { MikroORM } from '@mikro-orm/sqlite';
import { Book } from './book.entity.js';
@Injectable()
export class StatsService {
// The decorator looks for an `orm` (MikroORM) or `em` property on the class.
constructor(private readonly orm: MikroORM) {}
// Not an HTTP request (cron job, queue consumer...): open a context by hand.
@CreateRequestContext()
async countBooks(): Promise<number> {
return this.orm.em.count(Book);
}
// The same query without a context: the global EntityManager refuses to run.
countBooksWithoutContext(): Promise<number> {
return this.orm.em.count(Book);
}
}
Source: NestJS: MikroORM
The test proves both halves: countBooks() works, and the undecorated twin throws.
test/mikroorm.e2e-spec.ts)it('needs @CreateRequestContext() outside an HTTP request', async () => {
const stats = app.get(StatsService);
expect(await stats.countBooks()).toBe(1);
await expect(stats.countBooksWithoutContext()).rejects.toThrow(/context/i);
});
it('can be unit tested with repository tokens', async () => {
const mockedBooks = { findAll: async () => [] };
const moduleRef = await Test.createTestingModule({
providers: [
BooksService,
{ provide: getRepositoryToken(Book), useValue: mockedBooks },
{ provide: getRepositoryToken(Author), useValue: {} },
{ provide: EntityManager, useValue: {} },
],
}).compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
});
Source: NestJS: MikroORM
The error without a context is ValidationError: Using global EntityManager instance methods for context specific actions is disallowed, followed by a hint about allowGlobalContext; do not set that option to silence it in a server, because it shares one identity map between requests. If you combine @CreateRequestContext() with @Cron() or another method decorator, put it directly above the method and below the others. The decorator replaces the method with a wrapper, and decorators listed below it attach their metadata to the original method, which Nest never sees; the cron job would silently not be registered.
Two more notes. Do not use Nest’s ClassSerializerInterceptor with MikroORM entities: it relies on class-transformer, which does not understand Reference and Collection and can recurse until the stack overflows; use MikroORM’s own options (hidden, serializer, serialize(), wrap(entity).toObject()). In the end-to-end test the JSON of a populated book contained its author but not the uninitialised books collection, which is MikroORM’s default serialization. And the module closes the ORM in onApplicationShutdown(), so enable shutdown hooks if you need that on SIGTERM. For several databases, call forRoot() once per database with a unique contextName, set registerRequestContext: false on each and import MikroOrmModule.forMiddleware() once so one middleware covers all connections. Migrations and schema generation come from the MikroORM CLI (the end-to-end test used orm.schema.create() for a throwaway database); see the MikroORM documentation.
Sequelize
@nestjs/sequelize integrates the Sequelize ORM, and sequelize-typescript supplies the decorators for declaring models. Sequelize implements the Active Record pattern: you call methods on the model classes themselves. The official page uses MySQL; this page uses SQLite through the sqlite3 package. The Nest package peers on sequelize 6 (^6.3.5) and sequelize-typescript 2, so Sequelize 7 is not what @nestjs/sequelize 12 targets.
npm install @nestjs/sequelize sequelize sequelize-typescript sqlite3
Source: NestJS: Sequelize
Models
author.model.ts and book.model.tsimport { AllowNull, Column, HasMany, Model, Table, Unique } from 'sequelize-typescript';
import { Book } from './book.model.js';
@Table
export class Author extends Model {
@AllowNull(false)
@Unique
@Column
declare name: string;
@HasMany(() => Book)
declare books: Book[];
}
import { AllowNull, BelongsTo, Column, ForeignKey, Model, Table, Unique } from 'sequelize-typescript';
import { Author } from './author.model.js';
// Wrapping the type keeps TypeScript from emitting a direct reference to Author in
// the decorator metadata, which fails in ESM because of the circular import.
type Relation<T> = T;
@Table
export class Book extends Model {
@AllowNull(false)
@Column
declare title: string;
@AllowNull(false)
@Unique
@Column
declare isbn: string;
@ForeignKey(() => Author)
@Column
declare authorId: number;
@BelongsTo(() => Author)
declare author: Relation<Author>;
}
Source: NestJS: Sequelize
Two details differ from the official snippets, and both are required by the current Nest template. First, the attributes use declare. The template targets ES2023, where TypeScript emits class fields with define semantics, and a plain title: string; field would shadow the getters and setters that Sequelize installs. We removed declare from Book, compiled with tsc and created a book: the call succeeded but title, isbn and authorId came back undefined, and Sequelize printed a warning about public class fields shadowing attribute getters and setters. (Running the same file through Vitest with SWC did not reproduce it, so test through the compiled output as well.) Second, author is typed Relation<Author>, a local alias for T: with a direct Author type, tsc emitted __metadata("design:type", Author) and the application crashed at start-up with ReferenceError: Cannot access 'Author' before initialization, because the two model files import each other. The official page’s Photo[] field needs no alias because an array type emits Array.
Configuration and forFeature
SequelizeModule.forRoot() accepts everything the Sequelize constructor does, plus retryAttempts (default 10), retryDelay (default 3000 ms), autoLoadModels (default false), keepConnectionAlive and synchronize (default true, which applies to automatically loaded models). autoLoadModels adds every model registered through forFeature() to the models array, so the root module need not import them; models only referenced through an association are not added. Do not rely on synchronize beyond throwaway databases, for the same reasons given in Data Overview & TypeORM. The Sequelize instance is injectable everywhere without imports. Credentials belong in ConfigService, as the official page’s async example shows.
src/sequelize/app.module.ts and books.module.tsimport { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SequelizeModule } from '@nestjs/sequelize';
import { BooksModule } from './books.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
SequelizeModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
dialect: 'sqlite',
storage: config.getOrThrow<string>('DATABASE_FILE'),
autoLoadModels: true,
synchronize: true, // fine for a demo; use migrations in production
logging: false,
}),
}),
BooksModule,
],
})
export class AppModule {}
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { BOOKS_SERVICE, BooksController } from '../common/books.controller.js';
import { Author } from './author.model.js';
import { Book } from './book.model.js';
import { BooksService } from './books.service.js';
@Module({
imports: [SequelizeModule.forFeature([Author, Book])],
controllers: [BooksController],
providers: [BooksService, { provide: BOOKS_SERVICE, useExisting: BooksService }],
})
export class BooksModule {}
Source: NestJS: Sequelize
To use a model in another module, export SequelizeModule from the module that calls forFeature(). With several connections, naming each is mandatory: pass the name to forRoot() (name: 'albumsConnection'), to forFeature() and to @InjectModel() or @InjectConnection().
Models in services, and transactions
src/sequelize/books.service.tsimport { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { CreateBook } from '../common/create-book.dto.js';
import { Author } from './author.model.js';
import { Book } from './book.model.js';
@Injectable()
export class BooksService {
constructor(
@InjectModel(Book) private readonly bookModel: typeof Book,
@InjectModel(Author) private readonly authorModel: typeof Author,
private readonly sequelize: Sequelize,
) {}
findAll() {
return this.bookModel.findAll({ include: [Author], order: [['id', 'ASC']] });
}
// Managed transaction: commits when the callback resolves, rolls back when it
// throws. Every query must receive { transaction }.
createWithAuthor(dto: CreateBook) {
return this.sequelize.transaction(async (transaction) => {
const [author] = await this.authorModel.findOrCreate({
where: { name: dto.authorName },
transaction,
});
return this.bookModel.create(
{ title: dto.title, isbn: dto.isbn, authorId: author.id },
{ transaction },
);
});
}
}
Source: NestJS: Sequelize
sequelize.transaction(callback) is a managed transaction: it commits when the callback resolves and rolls back when it throws. The decisive detail is that every query inside must receive { transaction }; a query that omits it runs outside the transaction. There is no ambient transaction, unless you add continuation-local storage (see the nestjs-cls transactional plugin). The official page recommends wrapping the Sequelize object in a small helper (for example a TransactionRunner with a narrow interface) so that tests mock one thing instead of the whole Sequelize API.
Migrations and testing
Migrations are separate from the Nest source: the Sequelize CLI generates, runs and reverts them, and they cannot use dependency injection. A registered model is represented by a token that getModelToken() returns, so a unit test provides a mock under it.
test/sequelize.e2e-spec.ts)it('rolls back the author when the book insert fails', async () => {
await request(app.getHttpServer())
.post('/books')
.send({ title: 'Copy', isbn: '978-0441172719', authorName: 'New Author' })
.expect(409);
const names = (await Author.findAll()).map((a) => a.name);
expect(names).toEqual(['Frank Herbert']);
});
it('can be unit tested with a mock model token', async () => {
const mockModel = { findAll: async () => [] };
const moduleRef = await Test.createTestingModule({
providers: [
BooksService,
{ provide: getModelToken(Author), useValue: {} },
{ provide: getModelToken((await import('../src/sequelize/book.model.js')).Book), useValue: mockModel },
{ provide: Sequelize, useValue: {} },
],
}).compile();
expect(await moduleRef.get(BooksService).findAll()).toEqual([]);
});
Source: NestJS: Sequelize
Comparing the four
| Prisma | Drizzle | MikroORM | Sequelize | |
|---|---|---|---|---|
Nest package |
none (wrap |
|
|
|
Model is declared in |
|
TypeScript functions |
Decorated classes |
Decorated classes ( |
Pattern |
Generated client |
Query builder and relational queries |
Data Mapper and Unit of Work |
Active Record |
Inject with |
the |
|
|
|
Transaction |
|
|
|
|
Migrations |
|
|
MikroORM CLI |
Sequelize CLI |
Needs a per-request context |
no |
no |
yes (automatic for HTTP, |
no |
Unit-test seam |
class token |
|
|
|
If you want one query model across SQL databases and a strict, generated type surface, Prisma is the lowest-ceremony choice, at the cost of a generation step in every checkout and CI run. Drizzle suits teams who think in SQL and want no code generation, accepting a pre-1.0 Nest package and an RC ORM. MikroORM suits domain-heavy code that benefits from a unit of work, provided everyone understands request contexts. Sequelize is mature and familiar, but needs the most care with TypeScript (declare attributes, Relation<T> aliases). TypeORM remains the integration most Nest material assumes.
Pitfalls
-
Installing the wrong major.
npm install prismaresolves to a Prisma 8 release candidate on 2026-10-02, andnpm install drizzle-ormresolves to v0, not the v1 the relations example uses. Pinprisma@7anddrizzle-orm@rc. -
Skipping
prisma generate. The client is generated intosrcand not committed; run it in fresh checkouts and CI before building. -
Using
this.prismaor the injected database inside a transaction. Every ORM here needs the transaction handle (tx, the forkedem,{ transaction }) for each query. -
Async callbacks with a synchronous driver. Drizzle on
better-sqlite3rejects an async transaction callback after running its first statement; the write is not rolled back. -
Circular imports in ESM. Wrap single-valued relation types (
Rel<T>, aRelation<T>alias) or the application crashes at start-up with aReferenceError. -
Class fields that shadow Sequelize attributes. Use
declareon model attributes. -
No request context outside HTTP. MikroORM throws a
ValidationErrorin cron jobs and queue consumers unless the method has@CreateRequestContext(), and the decorator must sit below@Cron(). -
Several instances applying migrations at start-up. Run migrations as a release step, not from every replica.
-
Credentials in source. Read connection strings and passwords from
ConfigServiceor the environment, and keep.envout of version control. -
Returning model objects directly. Serialize deliberately; see Serialization. With MikroORM, do not use
ClassSerializerInterceptor.
What changed since the books were written
Books and tutorials from before 2026, including 2025 print books, describe an older generation of these tools. Check these points when you copy code from them:
-
Prisma ORM 7 requires a driver adapter, generates the client into the directory named by the
outputfield of theprisma-clientgenerator (placed insidesrchere so it compiles with the application), reads the connection URL from the Prisma config file, and no longer supports$on('beforeExit', …); connection management belongs inonModuleInit()andonModuleDestroy(). -
The Prisma
latesttag on npm now points to a Prisma 8 release candidate, so unpinned install commands in older articles no longer match the Nest documentation. -
@nestjs/drizzleis new and pre-1.0, and the Drizzle v1 relations API (defineRelations()) differs from the relations and relational queries of Drizzle v0; see the Drizzle "Upgrading to v1" guide before copying v0 snippets. -
MikroORM v7 moved decorators to
@mikro-orm/decorators/legacy, made the metadata provider explicit and no longer acceptsforRoot()without arguments;@UseRequestContext()became@CreateRequestContext()in v6. -
The Nest 12 template is ESM-only, so relative imports need
.jsextensions and circular entity imports needRel<T>-style wrappers. -
The official documentation was restructured in September 2026: the old
/techniques/,/recipes/prismaand/recipes/mikroormURLs redirect, and the current pages are the/data/ones cited here.
Related pages
-
Data Overview & TypeORM: the integration comparison and the TypeORM version of Bookshelf.
-
MongoDB with Mongoose: documents instead of rows.
-
Caching: caching query results.
-
Evolving the Database Model: the discipline behind every migration tool above.