Data
MikroORM without decorators
defineEntity keeps entities Bun-friendly — no reflect-metadata, no decorator transforms — and the service forks a fresh EntityManager for every operation.
On this page
Install#
This page is written for MikroORM 7. The PostgreSQL driver package re-exports the core; @mikro-orm/migrations adds the Migrator extension used below.
bun add @mikro-orm/core @mikro-orm/postgresql @mikro-orm/migrations zod @rhythmjs/middlewareEntities without decorators#
MikroORM's decorator style needs a metadata provider (reflect-metadata, or a ts-morph discovery pass), machinery Bun's transpiler doesn't owe you. defineEntity is MikroORM 7's decorator-free way to declare an entity: the metadata is an explicit object built from property builders, and no reflect-metadata is involved. onCreate fills the id and the timestamps when the entity is created, and onUpdate refreshes updatedAt on every change. NoteSchema.class is a generated class with fully inferred property types; extending it and calling setClass gives you a Note class to use as a type and as the argument to em.create and em.find.
// src/notes/note.entity.ts
import { defineEntity, p } from "@mikro-orm/core";
export const NoteSchema = defineEntity({
name: "Note",
tableName: "notes",
properties: {
id: p
.string()
.primary()
.onCreate(() => crypto.randomUUID()),
title: p.string(),
content: p.text().default(""),
createdAt: p
.datetime()
.fieldName("created_at")
.onCreate(() => new Date()),
updatedAt: p
.datetime()
.fieldName("updated_at")
.onCreate(() => new Date())
.onUpdate(() => new Date()),
},
});
export class Note extends NoteSchema.class {}
NoteSchema.setClass(Note);EntitySchema is still available if you prefer the explicit-object style, but the MikroORM docs now recommend defineEntity.
The database handle#
MikroORM.init is async, so createDatabase is too: main.ts awaits it once before serving, and the handle's close() calls orm.close() on shutdown. Registering the Migrator extension here means the same config serves the app and the migration runner.
// src/database.ts
import { join } from "node:path";
import { Migrator } from "@mikro-orm/migrations";
import { MikroORM } from "@mikro-orm/postgresql";
import { NoteSchema } from "./notes/note.entity";
const clientUrl = process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/notes_mikro_orm";
export interface Database {
orm: MikroORM;
close(): Promise<void>;
}
export async function createDatabase(): Promise<Database> {
const orm = await MikroORM.init({
clientUrl,
entities: [NoteSchema],
extensions: [Migrator],
migrations: { path: join(import.meta.dirname, "migrations"), snapshot: false },
});
return { orm, close: () => orm.close() };
}Fork per operation#
MikroORM's EntityManager carries an identity map — a unit of work that must not be shared across concurrent requests. The startup context holds the long-lived orm; every service method starts from orm.em.fork(), so each operation gets its own isolated context and the global-context guard never fires.
// src/notes/notes.service.ts
export function createNotesService(orm: MikroORM) {
return {
list: () => orm.em.fork().find(Note, {}, { orderBy: { createdAt: "desc" } }),
get: (id: string) => orm.em.fork().findOne(Note, { id }),
async create(input: CreateNoteInput) {
const em = orm.em.fork();
const note = em.create(Note, { title: input.title, content: input.content });
await em.flush();
return note;
},
async update(id: string, patch: UpdateNoteInput) {
const em = orm.em.fork();
const note = await em.findOne(Note, { id });
if (!note) return null;
em.assign(note, patch);
await em.flush();
return note;
},
async remove(id: string) {
const em = orm.em.fork();
const note = await em.findOne(Note, { id });
if (!note) return false;
await em.remove(note).flush();
return true;
},
};
}Three things to know about MikroORM 7 here. em.create marks the new entity for persistence (persistOnCreate, on by default), so flush() inserts it; there is no constructor to call. An entity loaded with findOne is already managed, so assign followed by flush() is enough and needs no persist. And the combined persistAndFlush and removeAndFlush helpers are gone: em.remove(note).flush() replaces removeAndFlush.
The module wiring is the shared pattern from the overview: filter(), the notes module that derives its service from orm (derive(({ orm }) => …)), the controller, a thrown 404. The app module is Rhythm<RhythmHttpContext, { orm: MikroORM }>, and main.ts assigns appModule.context.orm = database.orm.
Migrations#
Migrations are classes under src/migrations/ with up() and down() built from this.addSql(…). A short script opens the ORM, runs orm.migrator.up(), and closes it — MikroORM records applied migrations in a mikro_orm_migrations table.
// src/migrations/Migration20260930000000_init.ts
import { Migration } from "@mikro-orm/migrations";
export class Migration20260930000000_init extends Migration {
override async up(): Promise<void> {
this.addSql(`
create table "notes" (
"id" varchar(255) not null,
"title" varchar(255) not null,
"content" text not null default '',
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
constraint "notes_pkey" primary key ("id")
);
`);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "notes" cascade;`);
}
}// src/migrate.ts
const { orm, close } = await createDatabase();
const migrated = await orm.migrator.up();
await close();
console.log(`applied ${migrated.length} migration(s)`);Where to next#
Query-builder instead of unit-of-work: Drizzle. Generated client: Prisma. Document store: MongoDB. The full app is examples/mikro-orm-postgres.