Search docs

Jump between documentation pages.

Use MikroORM with DaloyJS

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 / MariaDB
pnpm add @mikro-orm/core @mikro-orm/mysql
# or SQLite
pnpm add @mikro-orm/core @mikro-orm/sqlite
# or libSQL / Turso
pnpm 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.ts
import { 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.ts
import { defineConfig } from "@mikro-orm/postgresql";
import { Migrator } from "@mikro-orm/migrations";
      import { User } from "./db/entities/User";

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.

ts
// src/db/plugin.ts
import type { App } from "@daloyjs/core";
import { MikroORM } from "@mikro-orm/postgresql";
import config from "../mikro-orm.config";

export const mikroOrmPlugin = {
  name: "mikro-orm",
  async register(app: App) {
    const orm = await MikroORM.init(config);
    app.decorate("orm", orm);
    app.onClose(async () => {
      await orm.close(true);
    });
  },
};

5. Fork an EntityManager per request

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.

ts
// src/db/middleware.ts
import 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

ts
// src/types/state.d.ts
import type { MikroORM, EntityManager } from "@mikro-orm/postgresql";

declare module "@daloyjs/core" {
  interface AppState {
    orm: MikroORM;
    em: EntityManager;
  }
}

7. Use the EntityManager in routes

ts
// src/server.ts
import { z } from "zod";
import { App } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { mikroOrmPlugin } from "./db/plugin";
import { requestEntityManager } from "./db/middleware";
import { User } from "./db/entities/User";

const app = new App();
app.register(mikroOrmPlugin);
app.use(requestEntityManager());

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().nullable(),
  createdAt: z.coerce.date(),
});

app.route({
  method: "GET",
  path: "/users/:id",
  operationId: "getUser",
  request: { params: z.object({ id: z.string().uuid() }) },
  responses: {
    200: { description: "Found", body: UserSchema },
    404: { description: "Not found" },
  },
  handler: async ({ params, state }) => {
    const user = await state.em.findOne(User, { id: params.id });
    return user
      ? { status: 200, body: user }
      : { status: 404, body: { type: "about:blank", title: "Not found", status: 404 } };
  },
});

app.route({
  method: "POST",
  path: "/users",
  operationId: "createUser",
  request: { body: z.object({ email: z.string().email(), name: z.string().optional() }) },
  responses: { 201: { description: "Created", body: UserSchema } },
  handler: async ({ body, state }) => {
    const user = state.em.create(User, { email: body.email, name: body.name ?? null });
    await state.em.flush();
    return { status: 201, body: user };
  },
});

await app.ready();
serve(app, { port: 3000 });

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.

ts
handler: async ({ body, state }) => {
  const order = await state.em.transactional(async (em) => {
    const created = em.create(Order, body);
    const inventory = await em.findOneOrFail(Inventory, { sku: body.sku });
    inventory.stock -= body.qty;
    return created;
  });
  return { status: 201, body: order };
}

Migrations

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 diff
pnpm mikro-orm migration:create

# apply all pending migrations
pnpm mikro-orm migration:up

# list pending migrations
pnpm mikro-orm migration:pending

# inspect the resolved CLI config
pnpm 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 and Vercel Edge 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.

Compare with Prisma, Drizzle, TypeORM, Sequelize, or the ODM overview if you need document models.