MikroORM is a TypeScript ORM built around a Data Mapper, Unit of Work, and Identity Map. It supports PostgreSQL, MySQL/MariaDB, SQLite, libSQL/Turso, PGlite, MSSQL, Oracle, and MongoDB. This SQL-focused guide targets MikroORM v7, PostgreSQL, and the Node.js adapter.
1. Install
Install @mikro-orm/core together with the driver package for your database. The version of every @mikro-orm/* package must match.
ts
# PostgreSQL (also CockroachDB)pnpm add @mikro-orm/core @mikro-orm/postgresql# or MySQL / MariaDBpnpm add @mikro-orm/core @mikro-orm/mysql# or SQLitepnpm add @mikro-orm/core @mikro-orm/sqlite# or libSQL / Tursopnpm add @mikro-orm/core @mikro-orm/libsql# or PGlite (embedded PostgreSQL in WASM)pnpm add @mikro-orm/core @mikro-orm/pglite# CLI + migrations (optional, dev-only)pnpm add -D @mikro-orm/cli @mikro-orm/migrations
MikroORM v7 supports both ES-spec decorators and the legacy experimentalDecorators flag. The examples below use the defineEntity helper, which is decorator-free and gives you full TypeScript inference without any compiler flags.
2. Define an entity
defineEntity returns a schema object you can attach to a real class. The class gives you a named type for your handlers; the schema gives MikroORM its metadata.
ts
// src/db/entities/User.tsimport { defineEntity, p } from "@mikro-orm/core";const UserSchema = defineEntity({ name: "User", properties: { id: p.uuid().primary().defaultRaw("gen_random_uuid()"), email: p.string().unique(), name: p.string().nullable(), createdAt: p.datetime().onCreate(() => new Date()), },});export class User extends UserSchema.class {}UserSchema.setClass(User);
3. Configure the ORM
Import defineConfig from your driver package, it infers the driver and gives you IntelliSense without extra type hints.
ts
// src/mikro-orm.config.tsimport { defineConfig } from "@mikro-orm/postgresql";import { Migrator } from "@mikro-orm/migrations"; import { User } from "./db/entities/User.ts";export default defineConfig({ entities: [User], clientUrl: process.env.DATABASE_URL, // production-friendly defaults debug: process.env.NODE_ENV !== "production", extensions: [Migrator], migrations: { path: "./dist/db/migrations", pathTs: "./src/db/migrations", },});
4. Create a MikroORM plugin
Initialize the ORM once at startup, decorate the app with the root ORM instance, and close it on shutdown. Handlers get their own forked EntityManager in step 5.
MikroORM relies on an Identity Map that is bound to an EntityManager. You must fork the root EM for every request so identity maps and unit-of-work state do not leak between concurrent handlers. Do it in middleware and expose the forked EM on state.
02beforeHandleFork per requeststate.em = state.orm.em.fork()
03handlerScoped queriesstate.em.create / findOne
04unit of workPersist on flushawait state.em.flush()
The plugin decorates one root ORM at startup. A beforeHandle hook forks a fresh EntityManager for each request onto state.em, so the identity map and unit-of-work state stay isolated, then a single flush() (or transactional()) persists the changes.
ts
// src/db/middleware.tsimport type { Hooks } from "@daloyjs/core";export function requestEntityManager(): Hooks { return { beforeHandle(ctx) { ctx.state.em = ctx.state.orm.em.fork(); }, };}
In Express-style middleware you will often see RequestContext.create(orm.em, next). Daloy hooks pass typed request state directly, so the simplest pattern is to use the forked state.em inside handlers.
6. Augment app state types
Add the declare module block to the same module that creates the plugin, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.orm / state.em quietly degrade to any.
ts
// src/db/plugin.ts (same module as the plugin above)import type { EntityManager } from "@mikro-orm/postgresql";declare module "@daloyjs/core" { interface AppState { orm: MikroORM; em: EntityManager; }}
MikroORM batches every change in the forked EM into a single flush(). You almost never need to call persist() manually when using em.create(), which auto-persists in v6+. Entities created with new User() still need em.persist().
Transactions
Use em.transactional() inside the handler that owns the unit of work. The callback receives a transactional EM that commits on success and rolls back if you throw.
The CLI is installed as a dev dependency and reads src/mikro-orm.config.ts by default. If you move the config under src/db, pass --config or configuremikro-orm.configPaths in package.json.
ts
# generate a migration from the current entity diffpnpm mikro-orm migration:create# apply all pending migrationspnpm mikro-orm migration:up# list pending migrationspnpm mikro-orm migration:pending# inspect the resolved CLI configpnpm mikro-orm debug
Errors
Translate MikroORM errors into framework errors so they serialize as problem+json:
ts
import { HttpError } from "@daloyjs/core";import { UniqueConstraintViolationException, NotFoundError } from "@mikro-orm/core";try { const user = state.em.create(User, { email: body.email, name: body.name ?? null }); await state.em.flush(); return { status: 201, body: user };} catch (err) { if (err instanceof UniqueConstraintViolationException) { throw new HttpError(409, { title: "User already exists", type: "https://daloyjs.dev/errors/duplicate", }); } if (err instanceof NotFoundError) { throw new HttpError(404, { title: "Not found" }); } throw err;}
Runtime notes
MikroORM is a Node.js adapter default for Daloy apps. Edge runtimes require precompiled MikroORM functions plus a compatible driver, so most Cloudflare Workers apps should start with Drizzle or Supabase instead.
Always fork the EM per request. Sharing the root EM across requests will leak the identity map and corrupt unit-of-work state.
Keep @mikro-orm/core, your driver package, the CLI, and @mikro-orm/migrations on the same version.