14. MikroORM: Data Mapper, Identity Map y Unit of Work
MikroORM no es «una librería para no escribir SQL». Es la implementación en TypeScript de tres patrones del catálogo clásico de arquitectura empresarial que, juntos, permiten escribir lógica de negocio con objetos normales y dejar que un componente externo se ocupe de traducirlos a filas, en el orden correcto y dentro de una transacción. Si entiendes Data Mapper, Identity Map y Unit of Work, el resto del ORM deja de parecer magia: cada comportamiento «raro» se vuelve predecible. Si no los entiendes, pasarás meses peleándote con flush(), con entidades que no se guardan y con datos que se filtran entre peticiones. Este capítulo es la base conceptual de toda la Parte IV.
14.1 Qué vas a poder hacer al terminar
- Explicar con precisión, en una entrevista o en una revisión de diseño, qué es el desajuste objeto-relacional y qué parte de ese problema resuelve un ORM (y qué parte no resuelve jamás).
- Distinguir Data Mapper de Active Record, justificar por qué MikroORM eligió el primero y decidir con criterio cuál encaja en un proyecto concreto.
- Predecir cuándo el ORM va a ejecutar SQL y cuándo no: por qué dos
findOneseguidos hacen una sola consulta, y por quépersist()no escribe nada en la base de datos. - Describir un
flush()paso a paso: cálculo de cambios, orden de commit, transacción, agrupación de sentencias y actualización del estado interno. - Enumerar los estados de una entidad (transitoria, gestionada, separada, eliminada) y qué método provoca cada transición.
- Explicar por qué un
EntityManagerno puede compartirse entre peticiones concurrentes, cómoRequestContextlo resuelve conAsyncLocalStoragey cómo se arregla el error «Using global EntityManager instance methods for context specific actions is disallowed». - Instalar y configurar MikroORM 6 con
defineConfig, elegir el metadata provider adecuado y separar la configuración por entornos y para tests. - Definir entidades con todas las opciones habituales de
@Property, elegir el tipo de clave primaria correcto y saber cuándo convieneEntitySchemaen lugar de decoradores. - Comparar MikroORM con TypeORM, Prisma, Drizzle, Sequelize y Knex con honestidad, y elegir con argumentos técnicos en lugar de por moda.
14.2 El desajuste objeto-relacional
El problema no lo inventó ningún ORM: existe desde que los lenguajes orientados a objetos y las bases de datos relacionales convivieron por primera vez. Se conoce como object-relational impedance mismatch (desajuste de impedancia objeto-relacional, expresión tomada de la electrónica) y describe que los dos modelos organizan la información con reglas incompatibles.
MUNDO DE OBJETOS (memoria) MUNDO RELACIONAL (disco)
────────────────────────── ────────────────────────
task TABLA task
│ id: 7 ┌────┬────────────┬────────────┐
│ title: 'Revisar PR' │ id │ title │ project_id │
│ done: false ├────┼────────────┼────────────┤
│ │ 7 │ Revisar PR │ 3 │
├─ project ──► project └────┴────────────┴────────────┘
│ │ id: 3
│ │ name: 'Libro' TABLA project
│ └─ tasks: [task, …] ┌────┬───────┐
│ (bidireccional) │ id │ name │
│ ├────┼───────┤
└─ tags: Collection[Tag, Tag] │ 3 │ Libro │
└────┴───────┘
Identidad: a === b (referencia)
Navegación: task.project.name TABLA task_tags (tabla puente)
Herencia: class A extends B ┌─────────┬────────┐
Tipos: Date, Map, enum, clases │ task_id │ tag_id │
Ciclos: permitidos y normales └─────────┴────────┘
Identidad: PRIMARY KEY
Navegación: JOIN … ON
Herencia: no existe
Tipos: int, varchar, timestamptz
Ciclos: hay que romperlos
14.2.1 Las cinco fricciones concretas
| Fricción | En objetos | En tablas | Consecuencia práctica |
|---|---|---|---|
| Identidad | Dos variables son «el mismo objeto» si comparten referencia (===) | Dos filas son la misma si comparten clave primaria | Si el ORM no lleva la cuenta, la misma fila puede acabar como dos objetos distintos e incoherentes en memoria. Lo resuelve el Identity Map. |
| Granularidad | Puedes crear clases pequeñas y expresivas: Dinero, Email, Direccion | Crear una tabla por cada concepto pequeño es caro y poco práctico | Necesitas embeddables y tipos personalizados para que varias propiedades vivan en columnas de la misma tabla (capítulo 15). |
| Herencia y polimorfismo | Natural: class Factura extends Documento | No existe. Hay que emularla | Tres estrategias imperfectas: tabla única con discriminador, tabla por clase o tabla por jerarquía. Cada una sacrifica algo. |
| Asociaciones y navegación | Referencias direccionales que se recorren con un punto: task.project.owner.email | Claves foráneas simétricas que se recorren con JOIN | Navegar sin pensar produce el problema N+1 (capítulo 16). Además, la bidireccionalidad hay que mantenerla a mano en memoria. |
| Tipos de datos | Date, bigint, enum, arrays, JSON, clases de valor | Un conjunto cerrado de tipos por motor, con precisión y zona horaria propias | Conversiones en los dos sentidos, y sorpresas clásicas: bigint que llega como cadena, DECIMAL que no es un number seguro, fechas sin zona horaria. |
14.2.2 Qué resuelve un ORM y qué NO resuelve
Sí resuelve
- El mapeo mecánico entre filas y objetos, en los dos sentidos, con conversión de tipos.
- La identidad: una fila, un objeto por contexto (Identity Map).
- La coordinación de escrituras: qué insertar antes de qué, en una sola transacción, agrupando sentencias (Unit of Work).
- El SQL repetitivo: los CRUD, los
JOINde carga de relaciones, la paginación. - La evolución del esquema con migraciones versionadas y revisables.
- La seguridad frente a inyección SQL: todo va parametrizado por defecto.
- El tipado: en MikroORM, los resultados están tipados a partir de tus entidades, incluidas las relaciones cargadas.
No resuelve
- No te libra de saber SQL. Para diagnosticar una consulta lenta hay que leer el SQL generado y su plan de ejecución.
- No diseña tus índices. El ORM crea los que declares; decidir cuáles hacen falta es trabajo tuyo y depende de tus consultas reales.
- No decide tus transacciones. Los límites transaccionales son una decisión de negocio: qué debe ser atómico y qué no.
- No arregla un modelo de datos malo. Un esquema sin normalizar o con claves mal elegidas será igual de malo con ORM.
- No sustituye al motor. Vistas materializadas, funciones de ventana, CTE recursivas, particionado o bloqueos avanzados siguen siendo SQL.
- No garantiza rendimiento. Es fácil escribir tres líneas inocentes que provoquen 500 consultas.
- No valida tus datos de entrada. Eso es del DTO y del validador (capítulo 10).
debug: ['query', 'query-params']) desde el primer día y mira el SQL que genera cada caso de uso que escribes. Un ORM usado a ciegas es una fábrica de problemas de rendimiento; un ORM usado con el log delante es una herramienta excelente. 14.2.3 Cuándo NO usar un ORM
Un profesional sabe también cuándo apartar su herramienta favorita. El coste de un ORM es la capa de abstracción: cuando esa capa no aporta nada, solo estorba.
| Escenario | Por qué el ORM estorba | Qué usar en su lugar |
|---|---|---|
Informes y analítica: agregaciones sobre millones de filas, GROUP BY con funciones de ventana, pivotados | No necesitas objetos ni identidad ni seguimiento de cambios: necesitas filas planas. Hidratar entidades es puro desperdicio de CPU y memoria | SQL crudo (em.getConnection().execute()) devolviendo objetos planos, o una vista de base de datos; en volúmenes grandes, un almacén analítico aparte |
| Cargas masivas: importar 5 millones de registros | El Identity Map crecería sin límite y el cálculo de diferencias multiplicaría el coste por fila | COPY de PostgreSQL, LOAD DATA de MySQL, o em.insertMany() por lotes con em.clear() entre lotes |
Consultas muy específicas del motor: CTE recursivas, búsqueda de texto completo con ranking, LATERAL, PostGIS | Expresarlas a través del ORM es más difícil de leer que el propio SQL | SQL crudo o el QueryBuilder para la parte estándar más fragmentos con el helper raw() (capítulo 16) |
| Scripts efímeros y utilidades de un solo uso | Configurar metadatos y descubrimiento para tres consultas no compensa | Un query builder ligero como Knex o Kysely, o el cliente del driver directamente |
| Servicios diminutos con dos tablas y sin lógica de dominio | La curva de aprendizaje del equipo supera el ahorro | Kysely o Drizzle: tipado fuerte, sin patrones de persistencia que aprender |
14.3 Historia y contexto: de Fowler a MikroORM 6
1990. Empiezan a aparecer capas de mapeo objeto-relacional en Smalltalk y C++. El término «ORM» se populariza con TopLink y con las primeras herramientas comerciales de Java.
2002 · Patterns of Enterprise Application Architecture. Martin Fowler publica el catálogo que da nombre a casi todo lo que usamos hoy. En su capítulo de patrones de arquitectura de datos define, entre otros, Active Record, Data Mapper, Identity Map, Unit of Work, Lazy Load, Repository, Query Object y Optimistic/Pessimistic Offline Lock. Esos nombres no son inventos de MikroORM: son vocabulario compartido desde hace más de veinte años, y por eso quien viene de Java o de PHP entiende MikroORM en una tarde.
2001–2006 · Hibernate y JPA. Gavin King crea Hibernate para Java implementando el catálogo de Fowler. Su éxito es tal que en 2006 el modelo se estandariza como JPA (Java Persistence API), con EntityManager, persist(), flush(), ciclo de vida de entidades y caché de primer nivel. Toda la terminología que verás en MikroORM viene de aquí.
2004 · Ruby on Rails. David Heinemeier Hansson populariza el patrón contrario, Active Record, hasta el punto de darle el nombre a la propia librería de persistencia de Rails. Marca a toda una generación de frameworks: Laravel Eloquent en PHP, Django ORM en Python, Sequelize en Node.
2006 · Doctrine (PHP). Jonathan Wage y más tarde Benjamin Eberlei y Guilherme Blanco llevan el modelo de Hibernate a PHP. Doctrine 2 (2010) es Data Mapper puro, con EntityManager, UnitOfWork e IdentityMap. Su documentación interna es tan buena que la propia documentación de MikroORM reconoce estar inspirada en ella.
2018 · nace MikroORM. Martin Adámek, desarrollador checo con experiencia previa en el ecosistema PHP/Doctrine, publica la primera versión con una premisa concreta: llevar Data Mapper, Identity Map y Unit of Work a TypeScript con tipado real, no con any disfrazado. El nombre viene de la idea de un núcleo pequeño («micro») sobre el que se añaden extensiones.
2020–2022 · v4 y v5. Soporte de múltiples drivers, QueryBuilder maduro, embeddables, filtros globales, result cache, seeders, integración oficial con NestJS y mejoras enormes de tipado (Loaded<T>, referencias envueltas).
2023 en adelante · v6. El salto que consolida el proyecto: defineConfig() importado del paquete del driver (adiós a la opción type y a los require() dinámicos que rompían los bundlers), tipado estricto de la carga parcial, Ref en lugar de IdentifiedReference, extensiones explícitas (Migrator, SeedManager, EntityGenerator), helper raw() obligatorio para fragmentos de SQL, y estrategia de carga joined por defecto en los drivers SQL. Este capítulo y los siguientes usan la sintaxis de la v6.
14.4 Data Mapper frente a Active Record
Es la primera decisión arquitectónica de cualquier capa de persistencia, y determina cómo se verá tu código de dominio durante los próximos cinco años.
ACTIVE RECORD · el objeto sabe guardarse a sí mismo
─────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────┐
│ class Task │
│ ┌────────────────────────────────┐ │
│ │ DATOS title, done, … │ │
│ ├────────────────────────────────┤ │
│ │ DOMINIO completar() │ │ ◄── tres responsabilidades
│ ├────────────────────────────────┤ │ en la MISMA clase
│ │ PERSISTENCIA save(), find(), │ │
│ │ remove(), count() │ │
│ └────────────────────────────────┘ │
└──────────────────┬───────────────────┘
│ SQL
▼
┌──────────────────┐
│ Base de datos │
└──────────────────┘
DATA MAPPER · un mapeador externo traduce entre objetos y filas
─────────────────────────────────────────────────────────────────────
┌──────────────────────────────────────┐
│ class Task │ entidad POJO:
│ DATOS title, done, … │ no conoce el ORM,
│ DOMINIO completar() │ se instancia con `new`
└──────────────────┬───────────────────┘ y se testea sin BD
│ la entidad NO conoce al mapeador
┌──────────────────▼───────────────────┐
│ EntityManager │ el MAPEADOR:
│ · Identity Map · UnitOfWork │ conoce tablas, columnas,
│ · metadatos · driver │ claves foráneas y orden
└──────────────────┬───────────────────┘
│ SQL
▼
┌──────────────────┐
│ Base de datos │
└──────────────────┘
14.4.1 Definiciones precisas
Active Record (Fowler, PoEAA): «un objeto que envuelve una fila de una tabla, encapsula el acceso a la base de datos y añade lógica de dominio sobre esos datos». La clase contiene a la vez el estado, el comportamiento de negocio y los métodos de persistencia. Instanciar equivale casi a «tener una fila»; llamar a save() escribe inmediatamente.
Data Mapper (Fowler, PoEAA): «una capa de mapeadores que mueve datos entre los objetos y la base de datos manteniéndolos independientes entre sí, y también independientes del propio mapeador». La entidad es un objeto normal que no sabe que existe una base de datos. Otro componente —en MikroORM, el EntityManager— es el único que conoce el esquema y ejecuta SQL.
14.4.2 El mismo caso en los dos estilos
Caso de uso: «marcar una tarea como completada y registrar la fecha, solo si el proyecto está activo».
// La entidad hereda capacidades de persistencia.
// (Sintaxis ilustrativa al estilo TypeORM en modo AR / Eloquent.)
@Entity()
class Task extends BaseEntity {
@PrimaryKey() id!: number;
@Property() title!: string;
@Property() done = false;
@Property({ nullable: true }) doneAt?: Date;
@ManyToOne() project!: Project;
// Lógica de dominio Y persistencia mezcladas
async completar(): Promise<void> {
// La entidad consulta la base de datos por su cuenta
const proyecto = await Project.findOneBy({ id: this.project.id });
if (!proyecto?.active) throw new Error('Proyecto inactivo');
this.done = true;
this.doneAt = new Date();
await this.save(); // ← escribe YA: UPDATE inmediato
}
}
// Uso desde el servicio
const task = await Task.findOneBy({ id: 7 });
await task.completar();
// La entidad solo contiene datos y reglas de negocio puras.
@Entity()
export class Task {
@PrimaryKey() id!: number;
@Property() title!: string;
@Property() done = false;
@Property({ nullable: true }) doneAt?: Date;
@ManyToOne(() => Project) project!: Project;
// Regla de negocio sin E/S: testeable sin base de datos
completar(ahora = new Date()): void {
if (this.done) throw new TaskYaCompletada(this.id);
this.done = true;
this.doneAt = ahora;
}
}
// El caso de uso orquesta; el EM persiste
@Injectable()
export class TasksService {
constructor(private readonly em: EntityManager) {}
async completar(id: number): Promise<void> {
const task = await this.em.findOneOrFail(Task, id, {
populate: ['project'],
});
if (!task.project.active) throw new ProyectoInactivo();
task.completar(); // 0 consultas: solo memoria
await this.em.flush(); // 1 UPDATE, en una transacción
}
}
Fíjate en la diferencia real, que no es estética: en la versión Data Mapper, Task.completar() es una función pura sobre el estado del objeto. Se puede probar con new Task() en un test unitario de dos líneas, sin base de datos, sin contenedor de inyección y sin dobles. En la versión Active Record, probar completar() exige una base de datos o un mock del método estático findOneBy.
14.4.3 Comparación honesta
| Criterio | Active Record | Data Mapper |
|---|---|---|
| Acoplamiento | Alto: la entidad depende del ORM y, a través de él, de la base de datos | Bajo: la entidad es un objeto normal; el ORM depende de ella, no al revés |
| Testabilidad | Regular: casi todo test toca la persistencia; se acaba usando SQLite en memoria para todo | Buena: el dominio se prueba sin E/S; solo los repositorios y casos de uso necesitan base de datos |
| Pureza del dominio | Nula por diseño: una clase con estado, reglas y SQL viola la responsabilidad única | Alta: separación explícita entre modelo y persistencia |
| Ergonomía inmediata | Excelente: user.save() es difícil de superar en brevedad | Algo más ceremoniosa: hay que inyectar el EM y acordarse de flush() |
| Control del SQL | Cada save() es un viaje. Muchos objetos, muchas sentencias | Un flush() agrupa todo el caso de uso en una transacción con sentencias por lotes |
| Curva de aprendizaje | Baja al principio; sube cuando aparecen transacciones y consistencia | Más alta al principio (hay tres patrones que entender); estable después |
| Encaje con DDD y hexagonal | Malo: el dominio arrastra la infraestructura | Natural: es prácticamente el diagrama del patrón |
| Quién lo usa | Rails ActiveRecord, Laravel Eloquent, Django ORM, Sequelize, TypeORM en modo Active Record | Hibernate/JPA, Doctrine 2, MikroORM, TypeORM en modo Data Mapper, Entity Framework |
BaseEntity obtienes Active Record; usando DataSource y repositorios, Data Mapper. Es flexible, pero esa dualidad también explica parte de su complejidad interna. MikroORM eligió un único modelo y lo llevó hasta el final, con Identity Map y Unit of Work de verdad, que es donde está la diferencia sustancial: los repositorios de TypeORM en modo Data Mapper no llevan seguimiento de cambios; cada repo.save() es una escritura. 14.4.4 Por qué el Data Mapper encaja con DDD y hexagonal
La arquitectura hexagonal (Alistair Cockburn, 2005) establece que el núcleo de la aplicación no debe depender de detalles de infraestructura: las dependencias apuntan hacia dentro. La base de datos es un detalle, un «puerto» que se implementa con un «adaptador». En ese esquema, el dominio define la interfaz TaskRepository y la infraestructura la implementa con el EntityManager; la flecha de dependencia va de fuera hacia dentro, nunca al contrario. Un dominio Active Record no puede cumplir esa regla, porque la entidad es el adaptador: la clase que contiene tus invariantes de negocio importa el ORM y ejecuta SQL.
Con MikroORM tienes dos grados de pureza posibles, y conviene elegirlo de forma consciente:
- Pragmático (recomendado en la mayoría de proyectos): las entidades llevan decoradores de MikroORM, pero ninguna lógica de persistencia. El dominio depende de los decoradores, que son metadatos declarativos y no ejecutan E/S. Simple, directo y suficiente.
- Purista: las entidades son clases sin ningún decorador y el mapeo se declara aparte con
EntitySchema(sección 14.9.6). El paquete de dominio no tiene ni una importación del ORM. Es la opción correcta cuando el dominio se publica como librería compartida o cuando hay una regla arquitectónica estricta. Lo veremos en detalle en el capítulo 20.
14.5 Identity Map
Definición (PoEAA): «un mapa que garantiza que cada objeto se cargue solo una vez, manteniendo cada objeto cargado en un mapa indexado por su identidad». En MikroORM vive dentro del UnitOfWork, que a su vez pertenece al EntityManager.
14.5.1 El problema que resuelve
Sin Identity Map, dos consultas que devuelven la misma fila producen dos objetos distintos. Y eso provoca dos problemas graves:
const t1 = await orm.findTask(7); // objeto A
const t2 = await orm.findTask(7); // objeto B, otra instancia de la MISMA fila
t1.title = 'Nuevo título';
console.log(t2.title); // 'Título viejo' ← incoherencia en memoria
console.log(t1 === t2); // false
await orm.save(t1);
await orm.save(t2); // ¿cuál gana? Sobrescritura silenciosa
// Y en el caso realmente peligroso:
const proyecto = await orm.findProject(3); // trae sus tareas
const tarea = proyecto.tasks.find((t) => t.id === 7); // objeto C
const otra = await orm.findTask(7); // objeto D
otra.done = true;
// el objeto C sigue diciendo done = false: la vista y la respuesta HTTP mienten
Los dos síntomas son: objetos incoherentes en memoria (la misma fila con estados distintos, con riesgo de perder cambios al escribir) y consultas repetidas por la misma fila dentro de una única petición.
14.5.2 Cómo funciona internamente
El Identity Map es, literalmente, un mapa cuya clave se compone del nombre de la entidad y su clave primaria serializada, y cuyo valor es la instancia. Junto a él, el Unit of Work mantiene un segundo mapa con la instantánea original de cada entidad cargada, que es lo que permite el seguimiento de cambios (sección 14.6).
EntityManager (fork de esta petición)
┌────────────────────────────────────────────────────────────────────┐
│ UnitOfWork │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ IDENTITY MAP clave ──► instancia │ │
│ ├──────────────────────────────────────────────────────────────┤ │
│ │ Task-7 ──► Task { id: 7, title: 'Revisar PR' } │ │
│ │ Task-9 ──► Task { id: 9, title: 'Escribir cap.' } │ │
│ │ Project-3 ──► Project { id: 3, name: 'Libro' } │ │
│ │ User-12 ──► User { id: 12, email: 'ana@ejemplo' } │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ INSTANTÁNEAS ORIGINALES (base del change tracking) │ │
│ ├──────────────────────────────────────────────────────────────┤ │
│ │ Task-7 ──► { title: 'Revisar PR', done: false, … } │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
em.findOne(Task, 7)
│
▼
¿existe la clave Task-7 en el mapa?
│
┌────┴──────────────────────────────┐
│ SÍ │ NO
▼ ▼
devuelve la MISMA instancia SELECT … FROM task WHERE id = 7
0 consultas SQL hidrata la instancia,
la registra en el mapa
y guarda su instantánea
14.5.3 Demostración con código
=== que sorprende a todo el mundoconst em = orm.em.fork(); // contexto limpio, con su propio Identity Map
// ── Caso 1: búsqueda por clave primaria dos veces ────────────────────────
const t1 = await em.findOne(Task, 7);
// SQL: select "t0".* from "task" as "t0" where "t0"."id" = 7 limit 1
const t2 = await em.findOne(Task, 7);
// SQL: (ninguno) ← la clave Task-7 ya está en el Identity Map
console.log(t1 === t2); // true ← MISMA instancia, no una copia igual
// ── Caso 2: búsqueda por otra propiedad ──────────────────────────────────
const t3 = await em.findOne(Task, { title: 'Revisar PR' });
// SQL: select "t0".* from "task" as "t0" where "t0"."title" = 'Revisar PR' limit 1
// SÍ se ejecuta: el mapa está indexado por clave primaria, no por título.
// Pero al recibir la fila, el ORM ve que id = 7 ya está en el mapa y,
// en lugar de crear otro objeto, DEVUELVE EL EXISTENTE.
console.log(t1 === t3); // true ← una consulta más, pero una sola instancia
// ── Caso 3: la misma fila alcanzada por dos caminos distintos ────────────
const proyecto = await em.findOneOrFail(Project, 3, { populate: ['tasks'] });
const desdeLaColeccion = proyecto.tasks.getItems().find((t) => t.id === 7);
console.log(t1 === desdeLaColeccion); // true ← coherencia garantizada
// ── Consecuencia práctica ────────────────────────────────────────────────
t1.title = 'Título nuevo';
console.log(t3.title); // 'Título nuevo'
console.log(desdeLaColeccion!.title); // 'Título nuevo'
// No hay forma de tener la fila 7 en dos estados distintos dentro de este EM.
resultCache. 14.5.4 Efectos: caché de primer nivel, memoria y em.clear()
- Es la «caché de primer nivel» (terminología heredada de Hibernate). Su alcance es el
EntityManager, es decir, normalmente una petición. No se comparte entre peticiones, no se invalida y no tiene tiempo de expiración: se destruye con el contexto. - Hace que
flush()pueda funcionar sinpersist(): al vaciar, el ORM recorre el Identity Map y compara cada entidad gestionada con su instantánea. - Crece de forma monótona. Cada entidad cargada permanece referenciada hasta que se limpia el contexto. En una petición HTTP normal es irrelevante; en un proceso largo (un worker, una migración de datos, un cron que recorre un millón de filas) es una fuga de memoria garantizada.
em.clear()vacía el Identity Map y la cola de cambios pendientes: todas las entidades pasan a estar separadas. Es la herramienta correcta en procesos por lotes. Ojo: los cambios no vaciados se pierden, así que se llama después deflush().- Un EM compartido entre peticiones es un fallo de seguridad, no solo de memoria. Lo vemos en la sección 14.7.
// Recorre 2 millones de tareas para exportarlas.
// El Identity Map acumula 2 millones de entidades
// MÁS 2 millones de instantáneas originales.
// Resultado: "JavaScript heap out of memory".
async exportarTodo() {
const tareas = await this.em.find(Task, {}); // 1) todo en memoria de golpe
for (const t of tareas) {
await this.escribirLinea(t);
}
}
// Variante igual de mala: paginar sin limpiar el contexto
async exportarPaginado() {
for (let offset = 0; ; offset += 500) {
const lote = await this.em.find(Task, {}, { limit: 500, offset });
if (!lote.length) break;
for (const t of lote) await this.escribirLinea(t);
// falta em.clear(): el mapa sigue creciendo lote a lote
}
}
// Lotes + limpieza del contexto: memoria constante.
async exportarTodo() {
const em = this.orm.em.fork(); // contexto propio del job
let offset = 0;
const TAM = 500;
for (;;) {
const lote = await em.find(Task, {}, {
limit: TAM,
offset,
orderBy: { id: 'asc' }, // orden estable: sin filas repetidas
});
if (lote.length === 0) break;
for (const t of lote) await this.escribirLinea(t);
await em.flush(); // si hubiera cambios, primero se persisten
em.clear(); // y AHORA se libera el Identity Map
offset += TAM;
}
}
// Alternativa aún mejor cuando solo lees: no hidrates entidades.
// Sin objetos gestionados no hay Identity Map que crezca.
const filas = await em.find(Task, {}, {
fields: ['id', 'title'], // carga parcial
disableIdentityMap: true, // no registrar en el mapa
});
disableIdentityMap: true: úsalo sabiendo lo que pierdes Con esa opción, las entidades devueltas no quedan gestionadas: los cambios que les hagas no se persistirán con flush() y no se garantiza la identidad con otras instancias ya cargadas. Es perfecto para lecturas de solo lectura (listados, exportaciones, respuestas de API) y peligroso si luego pretendes modificarlas. Para casos de solo lectura, en el capítulo 16 verás una opción todavía más eficiente: las proyecciones y los objetos planos. 14.6 Unit of Work
Definición (PoEAA): «mantiene una lista de los objetos afectados por una transacción de negocio y coordina la escritura de los cambios y la resolución de problemas de concurrencia». En una frase: es un cuaderno de notas donde el ORM apunta todo lo que hay que hacer y, cuando le dices flush(), lo ejecuta de golpe, en el orden correcto y dentro de una transacción.
14.6.1 Change tracking: la instantánea original
La pregunta natural en un Data Mapper puro es: si la entidad no sabe que existe una base de datos y nadie llama a save(), ¿cómo sabe el ORM qué cambió? La respuesta es sencilla y no usa proxies ni setters mágicos: cuando el ORM hidrata una entidad, guarda una copia plana de sus valores (la instantánea original) en el Unit of Work. En el flush(), compara la entidad con su instantánea campo a campo y genera un UPDATE solo con las columnas que hayan cambiado.
const task = await em.findOneOrFail(Task, 7);
// Instantánea guardada: { id: 7, title: 'Revisar PR', done: false, priority: 3, … }
task.done = true; // el objeto cambia; la instantánea NO
// La instantánea es accesible (API interna, útil para depurar):
console.log(em.getUnitOfWork().getOriginalEntityData(task));
// { id: 7, title: 'Revisar PR', done: false, priority: 3, … }
await em.flush();
// El diff detecta un único campo modificado:
// SQL: update "task" set "done" = true where "id" = 7
// ↑ no se envían title ni priority: menos tráfico y menos conflictos
// Tras el commit, la instantánea se ACTUALIZA: la entidad vuelve a estar "limpia".
await em.flush(); // 0 consultas: no hay diferencias que persistir
En un Data Mapper con Unit of Work, cambiar una propiedad de una entidad cargada equivale a programar un UPDATE. No hace falta llamar a persist(): la entidad ya está en el Identity Map. Por eso «tocar» una entidad para calcular algo temporal es un error muy grave.
Si necesitas una vista modificada de una entidad para responder al cliente, no muevas la entidad: construye un DTO. Es una de las razones de fondo por las que este libro insiste tanto en no devolver entidades desde los controladores.
14.6.2 Anatomía de un flush(), paso a paso
await em.flush()
│
▼
┌─ 1 ─ CÁLCULO DE CAMBIOS (compute change sets) ──────────────────────┐
│ Recorre las entidades gestionadas y las marcadas con persist(): │
│ · nueva (sin instantánea) ──► ChangeSet CREATE │
│ · gestionada con diff ──► ChangeSet UPDATE (solo campos │
│ sucios) │
│ · marcada con remove() ──► ChangeSet DELETE │
│ Propaga cascadas (persist/remove) por el grafo de relaciones y │
│ recorre las colecciones para detectar altas y bajas. │
└───────────────────────────────┬─────────────────────────────────────┘
│ ¿algún cambio?
│ NO ──► return (0 sentencias SQL)
▼ SÍ
┌─ 2 ─ ORDEN DE COMMIT (commit order) ────────────────────────────────┐
│ Orden topológico del grafo de dependencias de claves foráneas: │
│ Project antes de Task antes de TaskTag │
│ Los DELETE se ejecutan en orden INVERSO al de los INSERT. │
│ Los ciclos se rompen con "extra updates" diferidos. │
└───────────────────────────────┬─────────────────────────────────────┘
▼
┌─ 3 ─ APERTURA DE TRANSACCIÓN ───────────────────────────────────────┐
│ BEGIN (transacción implícita; implicitTransactions) │
│ Evento beforeFlush / onFlush de los EventSubscriber │
└───────────────────────────────┬─────────────────────────────────────┘
▼
┌─ 4 ─ EJECUCIÓN AGRUPADA (batching) ─────────────────────────────────┐
│ @BeforeCreate ──► insert into project (…) values (…), (…), (…) │
│ @AfterCreate ↑ un solo INSERT para N filas │
│ @BeforeUpdate ──► update task set … where id = … (por lotes) │
│ @AfterUpdate │
│ @BeforeDelete ──► delete from task_tags where … │
│ @AfterDelete ──► delete from task where id in (…) │
└───────────────────────────────┬─────────────────────────────────────┘
▼
┌─ 5 ─ CIERRE DE TRANSACCIÓN ─────────────────────────────────────────┐
│ COMMIT · si algo falla: ROLLBACK y se relanza el error │
└───────────────────────────────┬─────────────────────────────────────┘
▼
┌─ 6 ─ SINCRONIZACIÓN DEL ESTADO EN MEMORIA ──────────────────────────┐
│ · Se rellenan los valores generados por la BD (id, version, │
│ defaults, columnas calculadas) │
│ · Se ACTUALIZAN LAS INSTANTÁNEAS: las entidades quedan "limpias" │
│ · Las entidades nuevas pasan a GESTIONADAS y entran al mapa │
│ · Las eliminadas salen del Identity Map │
│ · Se vacía la cola de cambios (el Identity Map se CONSERVA) │
│ · Evento afterFlush │
└─────────────────────────────────────────────────────────────────────┘
id a las claves foráneas de las tareas. Con Unit of Work no piensas en ello. Sin él, es un error de violación de clave foránea a las tres semanas de estar en producción. 14.6.3 persist() no escribe; flush() sí
Es el malentendido número uno de quien llega a MikroORM. em.persist(entity) es una operación síncrona que no devuelve una promesa y no habla con la base de datos: solo apunta la entidad en el cuaderno del Unit of Work. La escritura ocurre en em.flush().
async crear(dto: CreateTaskDto): Promise<number> {
const task = new Task(dto.title);
this.em.persist(task); // no escribe NADA todavía
return task.id;
// ⇒ undefined: el id lo genera la base de datos
// y aquí no ha habido ningún INSERT.
}
async crearMuchas(dtos: CreateTaskDto[]) {
for (const dto of dtos) {
const task = new Task(dto.title);
// Un flush por iteración: N transacciones,
// N viajes de red, N recálculos de cambios.
await this.em.persistAndFlush(task);
}
}
async completarTodas(ids: number[]) {
const tareas = await this.em.find(Task, { id: { $in: ids } });
tareas.forEach((t) => t.completar());
// Falta el flush: la petición responde 200
// y en la base de datos no ha cambiado nada.
}
async crear(dto: CreateTaskDto): Promise<number> {
const task = new Task(dto.title);
this.em.persist(task);
await this.em.flush(); // INSERT dentro de una transacción
return task.id; // ahora sí: el ORM ha rellenado el id
}
async crearMuchas(dtos: CreateTaskDto[]) {
for (const dto of dtos) {
this.em.persist(new Task(dto.title));
}
// UN solo flush: una transacción y, con batching,
// un único INSERT con múltiples filas.
await this.em.flush();
}
async completarTodas(ids: number[]) {
const tareas = await this.em.find(Task, { id: { $in: ids } });
tareas.forEach((t) => t.completar());
// Las entidades ya están GESTIONADAS: no hace falta persist().
await this.em.flush(); // UPDATE por lotes
}
flush() por caso de uso
em.persistAndFlush(x) existe y es cómodo, pero acostumbra a vaciar constantemente y eso destruye las dos grandes ventajas del patrón: la atomicidad y la agrupación. La regla profesional es «un caso de uso, un flush, al final». Así el conjunto de la operación es atómico: si la tercera entidad falla una restricción, no queda nada a medias.
Excepción legítima: necesitas la clave generada de una entidad para una lógica intermedia que no es una simple asignación de relación (para relaciones, el ORM ya propaga la clave por ti). Entonces un flush() intermedio dentro de un em.transactional() mantiene la atomicidad.
em.create() ya llama a persist() Con la opción por defecto persistOnCreate: true, las entidades creadas con em.create(Task, {...}) quedan marcadas para persistencia automáticamente: te basta con flush(). Las creadas con new Task() no, salvo que cuelguen del grafo de una entidad ya gestionada con cascada. Saber cuál de los dos caminos estás usando evita mucha confusión. 14.6.4 Estados de una entidad y transiciones
em.persist(e) · em.create(Type, {…})
┌───────────────┐ ─────────────────────────────────► ┌───────────────┐
│ TRANSITORIA │ │ GESTIONADA │ ◄── em.find()
│ (new) │ │ (managed) │ em.findOne()
│ objeto normal│ │ en Identity │ hidratación
│ sin id, fuera│ │ Map, con │ desde la BD
│ del mapa │ │ instantánea │
└───────────────┘ └───┬───────┬───┘
│ │
em.clear() · em.fork() · fin de la │ │ em.remove(e)
petición │ │
┌───────────────┐ ◄────────────────────────────────────────┘ │
│ SEPARADA │ ▼
│ (detached) │ ┌───────────────┐
│ existe en la │ ───────────────────────────────────────► │ ELIMINADA │
│ BD, ningún EM │ em.merge(e) (vuelve a │ (removed) │
│ la vigila │ estar gestionada) │ marcada para │
└───────────────┘ │ DELETE │
└───────┬───────┘
│ em.flush()
▼
DELETE ejecutado y salida
del Identity Map
| Estado | Qué significa | ¿Lo ve flush()? | Cómo se llega |
|---|---|---|---|
| Transitoria (new) | Objeto JavaScript recién creado. No tiene clave primaria asignada por la base de datos y no está en el Identity Map. Para el ORM, no existe | No | new Task() |
| Gestionada (managed) | Está en el Identity Map de un EM concreto y tiene instantánea original. Cualquier cambio que le hagas se detectará | Sí | em.find*(), em.persist(), em.create(), em.merge(), cascada desde otra gestionada, o un flush() que inserta una transitoria |
| Eliminada (removed) | Sigue en memoria, pero está en la cola de borrado. Tras el flush() desaparece del mapa | Sí (genera DELETE) | em.remove(), cascada orphanRemoval |
| Separada (detached) | Representa una fila existente, pero ningún EM la vigila: no hay instantánea. Modificarla no produce ningún SQL. Es el estado de todo lo que sobrevive a un em.clear() | No | em.clear(), fin de la petición, entidad serializada y devuelta a un nuevo contexto, disableIdentityMap: true |
flush(). No lanza ningún error, no ejecuta ninguna consulta y no cambia nada: el EM no sabe que ese objeto existe. Ocurre típicamente al guardar entidades en una caché en memoria, en una variable de módulo o en una cola, y reutilizarlas en otra petición. La solución no es em.merge() como reflejo, sino guardar identificadores, no entidades, y volver a cargar en el nuevo contexto. 14.6.5 La API del Unit of Work que usarás a diario
| Método | ¿SQL? | Qué hace exactamente | Cuándo usarlo |
|---|---|---|---|
em.persist(e) | No | Marca una entidad (o array) como pendiente de inserción y la registra en el Identity Map. Es síncrono y devuelve el propio EM, así que se puede encadenar | Siempre que crees entidades con new |
em.remove(e) | No | Marca para borrado. La entidad sigue accesible hasta el vaciado | Borrados; recuerda que necesita la entidad cargada o una referencia |
em.flush() | Sí | Ejecuta los seis pasos de la sección 14.6.2. Si no hay cambios, no abre transacción ni envía nada | Una vez, al final del caso de uso |
em.persistAndFlush(e) | Sí | Atajo de los dos anteriores | Scripts, seeders y tests; en servicios, prefiere separarlos |
em.refresh(e) | Sí | SELECT que recarga la entidad desde la base de datos y descarta los cambios locales, actualizando también la instantánea | Tras un UPDATE nativo, o cuando sospechas que otro proceso cambió la fila |
em.clear() | No | Vacía Identity Map y cola de cambios: todo pasa a separado. Los cambios sin vaciar se pierden | Entre lotes de un proceso largo, siempre después de flush() |
em.merge(e) | No | Registra un objeto (o datos planos) como gestionado y ya existente en la base de datos, con su estado actual como instantánea. No consulta nada, así que si te equivocas engañas al ORM | Muy raro: hidratar desde una caché externa o desde un mensaje. Casi siempre es preferible volver a cargar con em.find*() |
em.transactional(cb) | Sí | Abre transacción explícita, ejecuta la función con un EM propio, hace flush() y COMMIT; ante una excepción, ROLLBACK | Cuando necesitas varios vaciados atómicos o control del nivel de aislamiento (capítulo 17) |
em.getUnitOfWork() | No | Acceso al Unit of Work: getIdentityMap(), getOriginalEntityData(e), computeChangeSets() | Depuración y tests; no es API de negocio |
14.6.6 Flush modes: cuándo puede vaciar el ORM por su cuenta
El modo de vaciado determina si el ORM puede decidir vaciar antes de una consulta para que esa consulta vea tus cambios pendientes. Se configura globalmente con flushMode, por EM con em.setFlushMode(), por fork, por transacción o por consulta.
| Modo | Comportamiento | Comentario |
|---|---|---|
FlushMode.AUTO | Por defecto. Vacía antes de una consulta solo si detecta que hay cambios pendientes que podrían afectar a su resultado (por ejemplo, hay Task pendientes y consultas Task) | Es la razón por la que a veces ves un INSERT en un punto donde no habías escrito flush(). No es un fallo: es coherencia de lectura |
FlushMode.COMMIT | Retrasa el vaciado hasta el commit de la transacción | Más predecible, a costa de que una consulta intermedia no vea tus cambios en memoria |
FlushMode.ALWAYS | Vacía antes de cada consulta | Máxima coherencia, peor rendimiento. Útil para depurar comportamientos raros |
14.6.7 Ventajas e inconvenientes del Unit of Work
Ventajas
- Menos viajes a la base de datos: N escrituras se convierten en unas pocas sentencias por lotes. En redes con 1-2 ms de latencia, la diferencia entre 200
INSERTy uno es abismal. - Atomicidad por defecto: el caso de uso completo entra en una transacción sin que escribas
BEGIN. - Orden automático: el orden topológico evita violaciones de clave foránea.
- Actualizaciones mínimas: solo se envían las columnas modificadas, lo que reduce el tráfico y los conflictos de escritura concurrente.
- Dominio limpio: las reglas de negocio no llaman a
save(); solo cambian estado. - Punto único de extensión: los eventos del vaciado permiten auditoría, eventos de dominio o outbox sin tocar los servicios.
Inconvenientes
- Comportamiento implícito: el SQL no está donde está tu código. Hay que aprender cuándo ocurre, y eso sorprende a quien viene de Active Record.
- Mutaciones accidentales que se convierten en
UPDATEno deseados. - Difícil de razonar en procesos largos: memoria creciente si no se limpia el contexto.
- El vaciado automático (
FlushMode.AUTO) puede colocar sentencias en momentos inesperados. - Coste de CPU del cálculo de diferencias con grafos de entidades muy grandes.
- Exige disciplina de contexto: un EM por petición, sin excepciones.
14.7 El EntityManager
El EntityManager es la fachada del ORM y el corazón del Data Mapper: es el mapeador. Es el único objeto que conoce a la vez tus entidades, los metadatos del mapeo, el Identity Map, el Unit of Work y el driver de la base de datos. Todo lo que hagas contra la base de datos pasa por él, directamente o a través de un repositorio.
14.7.1 Qué contiene y cuál es su ciclo de vida
┌──────────────────────────── MikroORM (singleton) ────────────────────────────┐
│ · configuración validada · MetadataStorage (metadatos de entidades) │
│ · driver + pool de conexiones · caché de metadatos │
│ · orm.em ──► EntityManager GLOBAL: solo sirve para hacer fork() │
└──────────────────────────────────┬───────────────────────────────────────────┘
│ orm.em.fork() (una vez por petición)
▼
┌──────────────────────── EntityManager (por petición) ────────────────────────┐
│ UnitOfWork ─── Identity Map ─── instantáneas ─── cola de cambios │
│ EntityRepository<T> (uno por entidad, en caché) │
│ transacción activa (si hay) · flushMode · filtros activos │
│ COMPARTE el pool de conexiones con el resto de la aplicación │
└──────────────────────────────────────────────────────────────────────────────┘
Ciclo de vida. El objeto MikroORM se crea una vez al arrancar el proceso (MikroORM.init()) y se destruye al apagarlo (orm.close(), enganchado al cierre ordenado de Nest). El EntityManager, en cambio, es barato y desechable: se crea uno por unidad de trabajo —normalmente una petición HTTP, un mensaje de cola o una ejecución de cron— y se abandona al terminar. No hay que cerrarlo: cuando nadie lo referencia, el recolector de basura se lleva con él su Identity Map. Lo que no se duplica es el pool de conexiones: todos los forks comparten el mismo, y las conexiones se piden y devuelven por consulta o por transacción.
14.7.2 Por qué NO puede compartirse entre peticiones
Un EntityManager lleva estado mutable con nombre y apellidos: el Identity Map y la cola de cambios. Compartirlo entre peticiones concurrentes significa compartir ese estado. En Node, donde un solo proceso atiende cientos de peticiones intercaladas en el mismo hilo, las consecuencias van del desperdicio de memoria a la fuga de datos entre usuarios.
Una API con dos endpoints sobre la misma entidad y un único EM global:
GET /invoices/42devuelve la factura sin relaciones. Responde{ id: 42, total: 100, customer: 7 }. Correcto.- Otro usuario, con permisos de administración, llama a
GET /invoices/42?include=customer. El servicio hacepopulate: ['customer']y devuelve la factura con el cliente completo, incluidos su NIF y su dirección. Correcto para ese usuario. - El primer usuario recarga
GET /invoices/42. Como la instancia sigue en el Identity Map compartido y ahora tiene la relacióncustomermarcada como cargada, la serialización la incluye. El usuario sin permisos recibe el NIF y la dirección del cliente.
No hay ningún fallo de autorización en el código: el escape se produce porque el estado de «qué está cargado» vive en el Identity Map. Añade a esto que dos peticiones pueden mutar la misma instancia a la vez y que un flush() de la petición A escribiría los cambios a medio hacer de la petición B, y entenderás por qué MikroORM directamente prohíbe usar el EM global.
@Injectable()
export class ReportsCron {
constructor(private readonly orm: MikroORM) {}
@Cron('0 3 * * *')
async generar() {
// Usa el EM GLOBAL: fuera de una petición no hay
// contexto asíncrono, así que el ORM lanza:
//
// ValidationError: Using global EntityManager instance
// methods for context specific actions is disallowed.
// If you need to work with the global instance's
// identity map, use `allowGlobalContext` configuration
// option or `fork()` instead.
const tareas = await this.orm.em.find(Task, {});
// ...
}
}
// Y el "arreglo" que NO hay que copiar del primer
// resultado de búsqueda que encuentres:
// allowGlobalContext: true
// Silencia el aviso y te devuelve exactamente el
// problema de memoria y de fuga de datos que describe
// la sección anterior.
import { CreateRequestContext, MikroORM } from '@mikro-orm/core';
@Injectable()
export class ReportsCron {
// El decorador necesita encontrar MikroORM, EntityManager
// o un EntityRepository en el propio objeto.
constructor(private readonly orm: MikroORM) {}
@Cron('0 3 * * *')
@CreateRequestContext() // crea un contexto (y un fork) propio
async generar() {
// this.orm.em resuelve al fork del contexto: seguro.
const tareas = await this.orm.em.find(Task, {});
// ...
await this.orm.em.flush();
}
}
// Alternativa explícita, sin decoradores, igual de válida
// y a veces más clara en scripts:
async generarManual() {
const em = this.orm.em.fork();
const tareas = await em.find(Task, {});
await em.flush();
}
@CreateRequestContext frente a @EnsureRequestContext
@CreateRequestContext() crea siempre un contexto nuevo, así que no debe anidarse: si un método decorado llama a otro método decorado, el segundo trabajará con un EM distinto y perderás la atomicidad. @EnsureRequestContext() reutiliza el contexto existente si ya hay uno y solo crea uno nuevo cuando hace falta: es la opción correcta para métodos que se invocan tanto desde una petición como desde un worker.
En la v5 el decorador se llamaba @UseRequestContext(). Si sigues un tutorial antiguo, ese es el cambio de nombre.
14.7.3 em.fork(): qué copia y qué no
| Elemento | ¿Se copia al hacer fork()? |
|---|---|
| Configuración, metadatos, driver y pool de conexiones | Se comparten (no se duplican) |
| Identity Map | No: el fork nace vacío (opción clear: true, por defecto) |
| Cola de cambios del Unit of Work | No: nace vacía |
| Transacción activa | No: el fork empieza sin transacción |
| Filtros globales y sus parámetros | Sí, se heredan |
flushMode y schema activo | Sí, se heredan (se pueden sobrescribir en las opciones) |
| Gestor de eventos y suscriptores | Se comparte, salvo freshEventManager: true |
| Resolución del contexto asíncrono | No por defecto: el fork es independiente. Con useContext: true pasa a respetar el RequestContext |
14.7.4 RequestContext y AsyncLocalStorage
Aquí se cierra el círculo con el capítulo 1. El problema es de diseño: la inyección de dependencias de Nest entrega, por defecto, la misma instancia de cada provider a todas las peticiones. Si el EntityManager inyectado fuera el global, tendríamos exactamente el problema de la sección 14.7.2. Y pasar el EM como parámetro por todas las capas sería insoportable.
La solución es AsyncLocalStorage del núcleo de Node: un almacén que acompaña a toda la cadena de await de una operación asíncrona, aislado por «ejecución». MikroORM lo envuelve en el helper RequestContext. Con eso, el orm.em global se convierte en un proxy: cada método que toca el Identity Map llama antes a em.getContext(), que busca el fork del contexto actual y delega en él.
PETICIÓN A (usuario Ana) PETICIÓN B (usuario Luis)
│ │
▼ ▼
MikroOrmMiddleware MikroOrmMiddleware
RequestContext.create(orm.em, next) RequestContext.create(orm.em, next)
│ │
▼ ▼
orm.em.fork() ──► EM-A orm.em.fork() ──► EM-B
│ Identity Map propio │ Identity Map propio
│ UnitOfWork propio │ UnitOfWork propio
▼ ▼
AsyncLocalStorage.run(EM-A, …) AsyncLocalStorage.run(EM-B, …)
│ todo el árbol de await de A │
│ ve EM-A, sin pasar parámetros │
▼ ▼
TasksController (idéntico, aislado)
│
▼
TasksService ──► this.em (¡el global inyectado por Nest!)
│
▼ em.getContext()
RequestContext.getEntityManager() ──► EM-A
│
▼
await em.flush() ──► BEGIN … INSERT/UPDATE … COMMIT
│ (transacción propia de A)
▼
fin de la petición: EM-A queda sin referencias
y el recolector libera su Identity Map
import { MikroOrmModule } from '@mikro-orm/nestjs';
@Module({
imports: [
// Sin argumentos, lee el mikro-orm.config.ts declarado para el CLI.
// Registra AUTOMÁTICAMENTE MikroOrmMiddleware, que llama a
// RequestContext.create() en cada petición.
// Con registerRequestContext: false lo desactivas para gestionarlo tú.
MikroOrmModule.forRoot(),
// Expone los EntityRepository<T> inyectables de estas entidades
MikroOrmModule.forFeature([Task, Project]),
],
})
export class AppModule {}
@Injectable()
export class TasksService {
// Nest inyecta el EntityManager global una sola vez, al arrancar.
// No importa: cada llamada resuelve al fork de SU petición.
constructor(private readonly em: EntityManager) {}
async listar(): Promise<Task[]> {
return this.em.find(Task, {}); // usa EM-A o EM-B según quién pregunte
}
}
- Código que se ejecuta fuera de una petición: cron, consumidores de BullMQ, comandos de consola, listeners de eventos,
onModuleInit. No hay contexto, y de ahí el error «Using global EntityManager instance methods for context specific actions is disallowed». Solución:@CreateRequestContext()o unfork()explícito. - Callbacks que escapan del contexto:
setTimeout,setIntervalo un.then()sinawaitlanzados dentro de una petición y ejecutados después de que termine. El almacén se propaga, pero el EM ya está abandonado. - Middleware registrado en el orden equivocado: si tu propio middleware usa el ORM antes de que se cree el contexto, verás el mismo error. El contexto debe abrirse antes de cualquier consumidor del ORM.
- Tests: no hay peticiones HTTP. Ahí sí es legítimo usar
allowGlobalContext: true(o la variableMIKRO_ORM_ALLOW_GLOBAL_CONTEXT) para no llenar los tests defork(). Es la única excepción razonable.
14.7.5 EntityRepository: qué es realmente
Conviene desmontar un malentendido muy extendido: en MikroORM, un EntityRepository<T>
no es una capa de abstracción sobre la base de datos. Es, literalmente, una fachada tipada sobre el mismo EntityManager: guarda una referencia al EM y el nombre de la entidad, y reenvía las llamadas rellenando ese primer argumento por ti.
const repo = em.getRepository(Task);
await repo.find({ done: false }); // ═ em.find(Task, { done: false })
await repo.findOneOrFail(7); // ═ em.findOneOrFail(Task, 7)
repo.getEntityManager() === em.getContext(); // true: es el MISMO EM
// En la v6 se ELIMINARON del repositorio los métodos de persistencia
// (persist, persistAndFlush, remove, removeAndFlush, flush) porque daban la
// falsa impresión de ser un contexto propio de la entidad. Ahora:
em.persist(task);
await em.flush();
| Situación | Qué usar | Por qué |
|---|---|---|
| Consultas variadas dentro de un caso de uso, y persistencia | EntityManager | Es el dueño del Unit of Work; evita inyectar seis repositorios en un servicio |
| Un servicio centrado en una sola entidad, con muchas consultas | EntityRepository<T> | Ahorra repetir el tipo en cada llamada y se lee mejor |
| Consultas de negocio recurrentes y con nombre propio | Repositorio personalizado | Es el punto de extensión previsto: encapsula QueryBuilder y filtros bajo un nombre del dominio |
| Aislar el dominio de MikroORM por completo | Interfaz de repositorio en el dominio y un adaptador en infraestructura | Es el puerto de la arquitectura hexagonal; nada que ver con EntityRepository (capítulo 20) |
import { EntityRepository } from '@mikro-orm/postgresql';
export class TaskRepository extends EntityRepository<Task> {
// Consulta con nombre del dominio: el "por qué" queda en el nombre
async buscarVencidas(hasta: Date): Promise<Task[]> {
return this.find(
{ done: false, dueDate: { $lt: hasta } },
{ populate: ['project'], orderBy: { dueDate: 'asc' } },
);
}
// Encapsula SQL avanzado sin filtrarlo al servicio
async contarPorProyecto(): Promise<{ projectId: number; total: number }[]> {
return this.createQueryBuilder('t')
.select(['t.project as projectId', 'count(t.id) as total'])
.where({ done: false })
.groupBy('t.project')
.execute();
}
}
// Se enlaza con la entidad mediante la opción `repository`
@Entity({ repository: () => TaskRepository })
export class Task {
// Este símbolo hace que em.getRepository(Task) devuelva TaskRepository TIPADO
[EntityRepositoryType]?: TaskRepository;
// ...
}
14.8 Instalación y configuración
14.8.1 Paquetes
# Núcleo + driver. El paquete del driver RE-EXPORTA todo @mikro-orm/core,
# así que en la v6 lo idiomático es importar SIEMPRE desde el driver.
npm i @mikro-orm/core @mikro-orm/postgresql
# Integración con NestJS (módulo, middleware de contexto, @InjectRepository)
npm i @mikro-orm/nestjs
# Extensiones (v6: hay que declararlas en `extensions` de la configuración)
npm i @mikro-orm/migrations # migraciones versionadas (capítulo 17)
npm i @mikro-orm/seeder # datos de prueba y factorías
# Herramientas de desarrollo
npm i -D @mikro-orm/cli # comando `mikro-orm`
npm i -D @mikro-orm/entity-generator # generar entidades desde una BD existente
npm i -D @mikro-orm/reflection # TsMorphMetadataProvider (opcional)
npm i -D @mikro-orm/sqlite # driver para los tests
# Comprobación rápida de que el CLI encuentra tu configuración
npx mikro-orm debug
14.8.2 Drivers soportados y sus particularidades
| Paquete | Clase de driver | Dependencia | Particularidades |
|---|---|---|---|
@mikro-orm/postgresql | PostgreSqlDriver | pg | La opción recomendada: RETURNING (una sola ida y vuelta al insertar), jsonb, arrays nativos, enums nativos, gen_random_uuid(), esquemas. Compatible con CockroachDB |
@mikro-orm/mysql | MySqlDriver | mysql2 | Sin RETURNING: los id generados se recuperan aparte. Cuidado con la colación y con utf8mb4 |
@mikro-orm/mariadb | MariaDbDriver | mariadb | Muy similar a MySQL; el JSON se maneja de forma distinta |
@mikro-orm/sqlite | SqliteDriver | sqlite3 | Ideal para tests (:memory:). Tipado laxo, sin ALTER completo: las migraciones recrean tablas |
@mikro-orm/better-sqlite | BetterSqliteDriver | better-sqlite3 | Misma semántica, API nativa síncrona y notablemente más rápida en tests |
@mikro-orm/libsql | LibSqlDriver | libsql | Compatible con SQLite; permite bases remotas al estilo Turso |
@mikro-orm/mssql | MsSqlDriver | tedious | Añadido durante la serie 6.x. Particularidades de IDENTITY, OFFSET/FETCH y colaciones |
@mikro-orm/mongodb | MongoDriver | mongodb | Sin QueryBuilder SQL ni migraciones de esquema; _id de tipo ObjectId; transacciones solo con replica set |
NULL difieren. Lo profesional es usar el mismo motor en desarrollo, tests de integración (contenedor efímero) y producción, y reservar SQLite en memoria para tests unitarios que no dependan de particularidades del motor. 14.8.3 mikro-orm.config.ts con defineConfig
import { defineConfig, UnderscoreNamingStrategy } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { SeedManager } from '@mikro-orm/seeder';
import { TsMorphMetadataProvider } from '@mikro-orm/reflection';
import 'dotenv/config'; // v6: los .env YA NO se cargan solos (solo las MIKRO_ORM_*)
const esProd = process.env.NODE_ENV === 'production';
// defineConfig() viene del PAQUETE DEL DRIVER: fija el driver y tipa las
// opciones específicas del motor. Sustituye a la antigua opción `type: 'postgresql'`,
// que hacía un require() dinámico y rompía webpack, Vite y Next.
export default defineConfig({
// ── Conexión ────────────────────────────────────────────────────────────
clientUrl: process.env.DATABASE_URL, // o host/port/user/password/dbName
dbName: process.env.DB_NAME ?? 'tasks',
schema: 'public',
// ── Entidades ───────────────────────────────────────────────────────────
// Opción A (recomendada): importación explícita. Funciona con cualquier
// bundler, es analizable estáticamente y falla al compilar si te equivocas.
entities: [Task, Project, User, TaskTag],
// Opción B: descubrimiento por rutas. Cómodo en proyectos grandes, pero exige
// mantener DOS listas coherentes y no sobrevive a un empaquetado agresivo:
// entities: ['./dist/**/*.entity.js'], entitiesTs: ['./src/**/*.entity.ts'],
// ── Metadatos ───────────────────────────────────────────────────────────
metadataProvider: TsMorphMetadataProvider, // ver 14.8.4
metadataCache: { enabled: true, pretty: !esProd },
// ── Extensiones (v6: explícitas) ────────────────────────────────────────
extensions: [Migrator, SeedManager],
migrations: { path: './dist/migrations', pathTs: './src/migrations', snapshot: true },
seeder: { path: './dist/seeders', pathTs: './src/seeders' },
// ── Registro y depuración ───────────────────────────────────────────────
debug: esProd ? false : ['query', 'query-params'],
logger: (mensaje) => logger.debug({ orm: true }, mensaje), // integra tu logger
highlighter: esProd ? undefined : new SqlHighlighter(),
// ── Pool de conexiones ──────────────────────────────────────────────────
pool: {
min: 2,
max: 10, // regla: max × réplicas ≤ max_connections del motor
acquireTimeoutMillis: 30_000, // cuánto esperar por una conexión libre
idleTimeoutMillis: 30_000,
},
// ── Opciones que van directas al driver ─────────────────────────────────
driverOptions: {
connection: { ssl: esProd ? { rejectUnauthorized: true } : false },
},
// ── Convenciones de nombres ─────────────────────────────────────────────
// Por defecto en SQL: createdAt ──► created_at, TaskTag ──► task_tag
namingStrategy: UnderscoreNamingStrategy,
// ── Fechas y validación ─────────────────────────────────────────────────
forceUtcTimezone: true, // guarda las fechas en UTC (por defecto desde la v7)
validate: true, // valida los tipos de propiedad antes de persistir
strict: true, // ...y NO los convierte en silencio: lanza error
validateRequired: true, // (por defecto) exige las propiedades obligatorias
// ── Caché de resultados ─────────────────────────────────────────────────
resultCache: { expiration: 5_000, global: false }, // opt-in por consulta
// ── Contexto ────────────────────────────────────────────────────────────
allowGlobalContext: false, // NUNCA true en producción (ver 14.7.2)
});
| Opción | Para qué sirve | Recomendación |
|---|---|---|
entities / entitiesTs | Clases o rutas donde buscar entidades | Importación explícita salvo que el proyecto sea enorme. Si usas rutas, mantén las dos listas y comprueba en producción que apuntan a .js |
metadataProvider | De dónde salen los tipos de las propiedades | Ver la tabla de 14.8.4 |
debug | true o lista de espacios: query, query-params, discovery, info | Activo en desarrollo, desactivado en producción (los parámetros pueden contener datos personales) |
logger | Función que recibe cada mensaje del ORM | Conéctala a tu logger estructurado (capítulo 13) para no perder trazas |
pool | Tamaño y tiempos de espera del pool | Dimensiona con la fórmula «instancias × max ≤ conexiones del motor»; con serverless, usa un pooler |
driverOptions | Opciones crudas del cliente (SSL, timezone, keepalive) | Última salida cuando el ORM no expone algo; se fusiona en profundidad |
schema | Esquema por defecto; base de la multi-tenencia por esquema | Explícito siempre; en PostgreSQL no dependas del search_path |
namingStrategy | UnderscoreNamingStrategy (SQL), EntityCaseNamingStrategy (sin cambios), MongoNamingStrategy | Decídelo antes de la primera migración: cambiarlo después renombra todas las columnas |
forceUtcTimezone | Guarda los Date en UTC en columnas sin zona | Actívalo. Guardar en hora local es una fuente inagotable de errores |
validate / strict | Validación de tipos de propiedad antes de persistir, y si se convierten o se lanza error | Ambas a true en desarrollo; en producción mide el coste antes de dejarlas |
resultCache | Caché de resultados de consulta (expiration, adapter, global) | Activación por consulta, nunca global «por si acaso»; con varias instancias, adaptador en Redis |
allowGlobalContext | Desactiva la comprobación del contexto | false en la aplicación; true solo en la configuración de tests |
14.8.4 Metadata provider: la decisión menos obvia y más importante
MikroORM necesita saber el tipo de cada propiedad para elegir el tipo de columna y para convertir valores. Hay dos formas de averiguarlo, y no son equivalentes.
ReflectMetadataProvider | TsMorphMetadataProvider | |
|---|---|---|
| De dónde saca los tipos | De los metadatos que emite el compilador con emitDecoratorMetadata | Analiza el código fuente TypeScript (o los .d.ts) con ts-morph |
| Paquete | Incluido en el núcleo (por defecto) | @mikro-orm/reflection |
| Arranque | Inmediato | Lento la primera vez (analiza los ficheros); imprescindible la caché de metadatos |
| Tipos que no deduce | Uniones, opcionales complejos, Ref<T>, genéricos: hay que declarar type a mano en el decorador | Prácticamente todos, incluidos los envueltos y los opcionales |
Requisitos de tsconfig | experimentalDecorators y emitDecoratorMetadata | Necesita las fuentes .ts o los .d.ts en el despliegue |
| Compatibilidad con SWC, esbuild, Vite | Buena (con el plugin de decoradores correspondiente) | Problemática: si el bundle no lleva las fuentes, falla en producción |
| Cuándo elegirlo | Por defecto. Es la opción segura y la más común hoy | Cuando quieras entidades sin anotar tipos a mano y controles el proceso de despliegue |
TsMorphMetadataProvider, la aplicación funciona en local y en el contenedor de producción falla con un error de metadatos o de tipo no reconocido, porque la imagen solo contiene dist/. Las soluciones son generar la caché de metadatos en la fase de compilación (npx mikro-orm cache:generate) e incluirla en la imagen, o pasarse a ReflectMetadataProvider. Si no tienes una razón concreta para usar ts-morph, la segunda opción te ahorrará el problema entero. 14.8.5 El CLI
{
"scripts": {
"orm": "mikro-orm",
"migration:create": "mikro-orm migration:create",
"migration:up": "mikro-orm migration:up"
},
"mikro-orm": {
"useTsNode": true,
"configPaths": [
"./src/mikro-orm.config.ts",
"./dist/mikro-orm.config.js"
]
}
}
npx mikro-orm debug # ¿qué config lee? ¿qué entidades descubre?
npx mikro-orm schema:create --dump # imprime el DDL SIN ejecutarlo
npx mikro-orm schema:update --dump # diferencia entre entidades y BD real
npx mikro-orm schema:fresh --run --seed # recrea el esquema (¡solo en desarrollo!)
npx mikro-orm migration:create # genera la migración con el diff detectado
npx mikro-orm migration:up # aplica las pendientes
npx mikro-orm migration:down # revierte la última
npx mikro-orm migration:list # estado de cada migración
npx mikro-orm seeder:run # ejecuta los seeders
npx mikro-orm cache:generate # precalcula la caché de metadatos (CI/Docker)
npx mikro-orm generate-entities --dump # entidades a partir de una BD existente
schema:update --run no es una estrategia de despliegue Sincronizar el esquema automáticamente es cómodo mientras prototipas y peligroso en cuanto hay datos: no sabe transformar, no versiona nada, no es reversible y puede decidir eliminar una columna con información dentro. En cualquier entorno con datos que importen, la única vía es migraciones (capítulo 17). Y si el CLI no encuentra la configuración, revisa configPaths o usa la variable MIKRO_ORM_CLI_CONFIG. 14.8.6 Configuración por entorno y para tests
import { defineConfig as postgres } from '@mikro-orm/postgresql';
import { defineConfig as sqlite } from '@mikro-orm/sqlite';
const comun = {
entities: [Task, Project, User, TaskTag],
forceUtcTimezone: true,
namingStrategy: UnderscoreNamingStrategy,
};
export const configDesarrollo = postgres({
...comun,
clientUrl: process.env.DATABASE_URL,
debug: ['query', 'query-params'],
extensions: [Migrator, SeedManager],
});
export const configProduccion = postgres({
...comun,
clientUrl: process.env.DATABASE_URL,
debug: false,
pool: { min: 2, max: 10 },
metadataCache: { enabled: true },
driverOptions: { connection: { ssl: { rejectUnauthorized: true } } },
extensions: [Migrator],
});
// Tests unitarios y de integración rápidos: SQLite en memoria.
// Cada suite arranca con un esquema limpio y sin contenedores.
export const configTest = sqlite({
...comun,
dbName: ':memory:',
debug: false,
// Única excepción legítima: en los tests no hay peticiones HTTP
// y no queremos un fork() en cada línea.
allowGlobalContext: true,
});
import { MikroORM } from '@mikro-orm/sqlite';
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init(configTest);
await orm.schema.createSchema(); // crea las tablas a partir de las entidades
});
beforeEach(async () => {
await orm.schema.clearDatabase(); // aislamiento: base limpia en cada test
orm.em.clear(); // ...y contexto limpio
});
afterAll(async () => {
await orm.close(true); // cierra el pool: si no, Jest se queda colgado
});
new, sin ORM ni base de datos; milisegundos. 2) Integración con SQLite en memoria: valida el mapeo, las relaciones y los casos de uso; rápido y sin infraestructura. 3) Integración con el motor real en un contenedor efímero: valida migraciones, restricciones, tipos y todo lo específico de PostgreSQL. Los tres son necesarios; el detalle está en el capítulo 13. 14.9 Entidades: lo esencial
Aquí solo cubrimos las entidades «planas». Relaciones, herencia, embeddables y colecciones tienen capítulo propio (el 15).
14.9.1 Anatomía de una entidad
import {
Entity, PrimaryKey, Property, Enum, Index, Unique, Formula, Opt,
} from '@mikro-orm/postgresql';
export enum TaskStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
DONE = 'done',
}
@Entity({ tableName: 'tasks' }) // sin tableName: 'task' (naming strategy)
@Index({ properties: ['status', 'dueDate'] }) // índice compuesto
export class Task {
@PrimaryKey()
id!: number; // serial / identity
@Property({ length: 200 })
title!: string; // varchar(200) not null
@Property({ type: 'text', nullable: true })
description?: string; // text null
@Enum({ items: () => TaskStatus, nativeEnumName: 'task_status' })
status: TaskStatus = TaskStatus.PENDING;
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
estimatedHours?: string; // decimal: NO uses number (pierde precisión)
@Property({ nullable: true })
dueDate?: Date; // timestamptz null
// Se rellenan solos: onCreate/onUpdate se ejecutan durante el flush.
// `Opt` (v6) le dice a TypeScript que no hace falta pasarlo a em.create().
@Property({ onCreate: () => new Date() })
createdAt!: Date & Opt;
@Property({ onUpdate: () => new Date(), nullable: true })
updatedAt?: Date;
@Property({ version: true })
version!: number; // bloqueo optimista (capítulo 17)
@Property({ hidden: true, nullable: true })
internalNotes?: string; // nunca aparece en toObject()/toJSON()
@Property({ persist: false })
urlPublica?: string; // vive en memoria; no existe como columna
@Formula((alias) => `(${alias}.due_date < now() and ${alias}.status != 'done')`)
overdue?: boolean; // lo calcula el SELECT, no JavaScript
// Getter puro: no es una columna y no se serializa por defecto
get resumen(): string {
return `[${this.status}] ${this.title}`;
}
}
Opción de @Property | Qué hace | Nota práctica |
|---|---|---|
type | Tipo lógico o instancia de un tipo personalizado ('text', 'uuid', new BigIntType('string')) | Obligatorio cuando el proveedor de metadatos no puede deducirlo. En la v6 sustituyó a customType |
columnType | Tipo de columna literal del motor ('timestamptz(3)', 'jsonb') | Escotilla de escape: se escribe tal cual en el DDL, sin portabilidad |
nullable | Permite NULL | Debe ir de la mano de ? en TypeScript; si no, el tipo miente |
unique | Restricción de unicidad en una columna | Para varias columnas, @Unique({ properties: [...] }) en la entidad |
default | Valor por defecto en el DDL | No rellena el objeto en memoria: tras el INSERT hay que recargarlo si lo necesitas |
defaultRaw | Expresión SQL por defecto ('now()', 'gen_random_uuid()') | Se evalúa en el servidor de base de datos |
length | Longitud o precisión temporal (varchar(n), timestamptz(n)) | En la v6, un Date sin length es timestamptz con precisión de microsegundos |
precision / scale | Dígitos totales y decimales de decimal/numeric | Para dinero: decimal más string o un tipo Dinero. Nunca number |
fieldName | Nombre real de la columna | Solo para bases heredadas; con naming strategy no hace falta |
hidden | Excluye la propiedad de toObject() y toJSON() | Útil para passwordHash, pero no es seguridad: sigue en memoria |
persist: false | La propiedad existe en el objeto pero no se mapea a ninguna columna | Base de las propiedades virtuales y de los campos calculados en memoria |
lazy: true | No se selecciona salvo que se pida explícitamente | Para columnas grandes (contenido, blobs) que casi nunca hacen falta |
onCreate / onUpdate | Función que calcula el valor al insertar o al actualizar | La forma idiomática de createdAt y updatedAt |
version: true | Columna de versión para bloqueo optimista (number o Date) | El UPDATE incluye la versión en el WHERE; si no coincide, error de concurrencia |
concurrencyCheck: true | Incluye esa propiedad en el WHERE de los UPDATE | Bloqueo optimista sin columna de versión, comprobando campos concretos |
14.9.2 Tipos de clave primaria
// 1) Autoincremental: el defecto. Compacta (4-8 bytes), ordenada, índices densos.
// Inconveniente: es adivinable y revela volumen ("enumeración de recursos").
@PrimaryKey()
id!: number;
// 2) UUID v4 generado en la aplicación: opaco y generable sin ir a la base de datos.
@PrimaryKey({ type: 'uuid' })
id: string = randomUUID(); // node:crypto
// 3) UUID v4 generado por PostgreSQL
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
id!: string;
// 4) UUID v7: LA OPCIÓN RECOMENDADA hoy si necesitas identificadores opacos.
// Los 48 bits más significativos son una marca de tiempo en milisegundos,
// así que los valores son MONÓTONAMENTE CRECIENTES.
@PrimaryKey({ type: 'uuid' })
id: string = uuidv7(); // paquete `uuidv7`
// 5) Clave compuesta: varias @PrimaryKey y el símbolo PrimaryKeyProp (v6),
// que en la v5 se llamaba PrimaryKeyType y admitía una unión.
@Entity()
export class TaskAssignment {
@ManyToOne(() => Task, { primary: true }) task!: Task;
@ManyToOne(() => User, { primary: true }) user!: User;
[PrimaryKeyProp]?: ['task', 'user']; // el ORDEN importa
@Property() assignedAt: Date = new Date();
}
// 6) Clave natural: un valor del dominio que ya identifica de forma única.
@Entity()
export class Country {
@PrimaryKey({ length: 2 })
code!: string; // 'ES', 'PT', 'FR'
}
Un índice B-tree guarda las claves ordenadas. Un UUID v4 es aleatorio, así que cada inserción cae en una página cualquiera del índice: la base de datos tiene que leer y modificar páginas dispersas («fragmentación de páginas»), el número de divisiones de página se dispara y las páginas calientes dejan de caber en memoria. El UUID v7 empieza por una marca de tiempo, de modo que las claves nuevas van casi siempre al final del índice, igual que un autoincremental: inserciones más rápidas, índices más compactos y mejor localidad de caché.
Regla práctica: entero autoincremental si el identificador no se expone; UUID v7 si se expone en URL o hay generación distribuida; UUID v4 solo si la imposibilidad de ordenar es un requisito de privacidad explícito.
14.9.3 Índices, unicidad y restricciones
@Entity()
// Índice compuesto: el ORDEN de las propiedades determina qué consultas aprovecha.
// (status, dueDate) sirve para filtrar por status, o por status Y dueDate;
// NO sirve para filtrar solo por dueDate.
@Index({ properties: ['status', 'dueDate'], name: 'idx_task_status_due' })
// Unicidad compuesta: no puede haber dos tareas con el mismo título en un proyecto
@Unique({ properties: ['project', 'title'] })
// Índice parcial (solo PostgreSQL): mucho más pequeño y más rápido si la mayoría
// de las filas no cumplen la condición. Requiere expresión SQL literal.
@Index({
name: 'idx_task_pending',
expression: 'create index idx_task_pending on tasks (due_date) where status = \'pending\'',
})
// Restricción de comprobación a nivel de tabla
@Check({ expression: 'estimated_hours is null or estimated_hours > 0' })
export class Task { /* ... */ }
14.9.4 Clases base abstractas
// abstract: true ⇒ NO genera tabla; sus propiedades se copian a cada hija.
// No confundir con la herencia de tabla única (capítulo 15).
@Entity({ abstract: true })
export abstract class BaseEntity {
@PrimaryKey({ type: 'uuid' })
id: string = uuidv7();
@Property({ onCreate: () => new Date() })
createdAt!: Date & Opt;
@Property({ onUpdate: () => new Date(), nullable: true })
updatedAt?: Date;
@Property({ version: true })
version!: number;
}
@Entity()
export class Task extends BaseEntity {
@Property() title!: string; // hereda id, createdAt, updatedAt y version
}
14.9.5 EntitySchema: entidades sin decoradores
// El DOMINIO: una clase de TypeScript sin una sola importación de MikroORM.
// Se puede publicar en un paquete compartido con el frontend.
export class Task {
id!: number;
title!: string;
status: TaskStatus = TaskStatus.PENDING;
createdAt!: Date;
project!: Project;
completar(): void { this.status = TaskStatus.DONE; }
}
// La INFRAESTRUCTURA: el mapeo, declarado aparte y tipado contra la clase.
import { EntitySchema } from '@mikro-orm/postgresql';
export const TaskSchema = new EntitySchema<Task>({
class: Task,
tableName: 'tasks',
properties: {
id: { type: 'number', primary: true },
title: { type: 'string', length: 200 },
status: { enum: true, items: () => TaskStatus, default: TaskStatus.PENDING },
createdAt: { type: 'Date', onCreate: () => new Date() },
// En la v6 la clase de relación se declara con `kind` (antes `reference`)
project: { kind: 'm:1', entity: () => Project },
},
indexes: [{ properties: ['status', 'createdAt'] }],
});
// En la configuración se registra el ESQUEMA, no la clase
export default defineConfig({ entities: [TaskSchema, ProjectSchema] });
| Usa decoradores cuando… | Usa EntitySchema cuando… |
|---|---|
| Es una aplicación normal y el pragmatismo manda: menos código y todo en un sitio | El dominio se publica como paquete compartido y no puede depender del ORM |
| Quieres que la definición y el tipo vivan juntos | Hay una regla arquitectónica que prohíbe importaciones de infraestructura en el dominio |
| El equipo ya conoce los decoradores de Nest y Angular | Trabajas en JavaScript puro, o necesitas varios mapeos de la misma clase (varios motores o esquemas) |
| Quieres generar el mapeo de forma dinámica (multi-tenencia, plugins) |
14.9.6 strictPropertyInitialization y el operador !
Como vimos en el capítulo 1, con strict: true el compilador exige que toda propiedad no opcional se inicialice. En una entidad, el valor lo pone el ORM al hidratar desde la base de datos, no tu código, así que se usa el operador de aserción de asignación definida: id!: number. Es correcto y esperado; no es el ! abusivo que oculta errores de lógica.
@Entity()
export class Task {
// Marcar como opcional lo que en la BD es NOT NULL:
// el tipo miente y contagia comprobaciones inútiles
// (`task.title?.toUpperCase()`) a toda la aplicación.
@Property()
title?: string;
// Valor por defecto falso solo para callar al compilador:
// si el ORM lo hidrata, se sobrescribe; si no, tienes
// una cadena vacía viajando por el dominio.
@Property()
status: string = '';
// `any` para esquivar el problema del tipo
@Property()
dueDate: any;
}
@Entity()
export class Task {
// NOT NULL y lo rellena el ORM ⇒ aserción definida
@Property()
title!: string;
// Con valor por defecto REAL del dominio: sin `!`.
// `Opt` le indica a em.create() que no es obligatorio pasarlo.
@Property()
status: TaskStatus & Opt = TaskStatus.PENDING;
// Columna anulable ⇒ opcional en TypeScript. Los dos coinciden.
@Property({ nullable: true })
dueDate?: Date;
// Constructor con lo imprescindible: garantiza invariantes
// y permite `new Task('título')` en los tests.
constructor(title: string) {
this.title = title;
}
}
14.9.7 wrap(entity) y el WrappedEntity
Las entidades no llevan métodos del ORM (Data Mapper puro), así que las utilidades sobre una entidad viven en un objeto envoltorio al que se accede con wrap().
import { wrap } from '@mikro-orm/postgresql';
// getReference() crea un PROXY no inicializado: solo conoce la clave primaria.
// No hay ninguna consulta: sirve para asignar relaciones sin cargar la fila.
const ref = em.getReference(Project, 3);
console.log(wrap(ref).isInitialized()); // false
// init() lo materializa (SELECT) y devuelve la entidad ya cargada
await wrap(ref).init();
console.log(wrap(ref).isInitialized()); // true
console.log(ref.name); // ahora sí está disponible
// toObject(): DTO plano respetando `hidden` y las pistas de populate.
// toJSON() hace lo mismo (es lo que usa JSON.stringify).
const dto = wrap(task).toObject();
// assign(): aplica cambios parciales con validación de tipos.
// Es la base de un PATCH y respeta el change tracking.
wrap(task).assign({ title: 'Nuevo título', status: TaskStatus.DONE });
// equivalente: em.assign(task, { ... })
// Clave primaria de forma genérica (útil con claves compuestas)
console.log(wrap(task).getPrimaryKey());
// Acceso a los internos (segundo parámetro true): para depurar
const h = wrap(task, true);
console.log(h.__initialized, h.__managed, h.__originalEntityData);
Cannot read properties of undefined más habitual del ORM Acceder a una relación no inicializada: task.project.name cuando project es una referencia sin cargar. Y desde la v6, iterar una colección no inicializada lanza un error en lugar de fallar en silencio, lo cual es una mejora. Soluciones: populate en la consulta, em.populate(entity, [...]) después, o declarar la relación con ref: true para que el tipo Ref<T> te obligue a llamar a load(). El detalle está en el capítulo 15. 14.9.8 Hooks de entidad y EventSubscriber
@Entity()
export class Task {
// Se ejecutan DENTRO del flush, alrededor del INSERT/UPDATE/DELETE
// correspondiente, y por tanto dentro de la transacción.
@BeforeCreate()
@BeforeUpdate()
normalizar(): void {
this.title = this.title.trim();
this.slug = slugify(this.title);
}
@AfterCreate()
registrarCreacion(args: EventArgs<Task>): void {
// args.em está disponible, pero NO hagas flush aquí (ver más abajo)
}
// @OnInit se dispara al CREAR LA INSTANCIA, incluidas las referencias
// no inicializadas: la entidad puede no tener datos todavía.
@OnInit()
inicializar(): void {
this.uiState ??= {};
}
// @OnLoad se dispara cuando la entidad está COMPLETAMENTE cargada.
// Puede ser asíncrono.
@OnLoad()
async trasCargar(args: EventArgs<Task>): Promise<void> { /* ... */ }
}
// Un subscriber es una clase externa: sirve para varias entidades y, sobre todo,
// tiene acceso a los eventos GLOBALES del flush, que los hooks no tienen.
export class AuditSubscriber implements EventSubscriber {
// Sin este método, se suscribe a TODAS las entidades
getSubscribedEntities(): EntityName<AnyEntity>[] {
return [Task, Project];
}
// beforeFlush: el ÚNICO punto donde todavía puedes añadir entidades
// o modificar otras y contar con que se persistan en el mismo flush,
// porque los change sets aún no están calculados.
async beforeFlush(args: FlushEventArgs): Promise<void> {
for (const cs of args.uow.getChangeSets()) {
if (cs.type === ChangeSetType.UPDATE) {
args.em.persist(new AuditLog(cs.name, cs.getPrimaryKey(), cs.payload));
}
}
}
// afterFlush: ya está confirmado. Aquí es seguro publicar eventos
// de dominio o encolar trabajos, no antes.
async afterFlush(args: FlushEventArgs): Promise<void> {
await this.bus.publicarPendientes();
}
}
// v6: el decorador @Subscriber() se eliminó. Se registran en la configuración,
// que además admite la clase, no solo una instancia.
export default defineConfig({ subscribers: [AuditSubscriber] });
| Hooks de entidad | EventSubscriber | |
|---|---|---|
| Dónde se declaran | Métodos decorados en la propia entidad | Clase aparte registrada en subscribers |
| Alcance | Una entidad | Varias entidades, o todas |
Eventos del vaciado (beforeFlush, onFlush, afterFlush) | No disponibles | Disponibles |
| Testabilidad | Van pegados al dominio; se ejecutan siempre | Se prueban por separado y se pueden desactivar |
| Cuándo usarlo | Normalización trivial y coherencia interna de esa entidad | Auditoría, eventos de dominio, outbox, multi-tenencia, cifrado de campos |
@Entity()
export class Task {
@AfterUpdate()
async notificar(args: EventArgs<Task>): Promise<void> {
// 1) Un flush DENTRO de un flush: reentrada,
// cambios que no se detectan y, en el mejor
// de los casos, un comportamiento impredecible.
await args.em.flush();
// 2) Llamada HTTP dentro de la transacción: la
// mantiene abierta y bloqueando filas mientras
// espera a un tercero. Si el COMMIT falla luego,
// el correo ya se envió: efecto irreversible.
await this.mailer.enviar(this.assignee.email);
// 3) Da por hecho que la relación está cargada:
// TypeError si `assignee` es una referencia.
console.log(this.assignee.name);
}
}
// El hook queda solo para lo trivial y síncrono
@Entity()
export class Task {
@BeforeCreate()
@BeforeUpdate()
normalizar(): void {
this.title = this.title.trim();
}
}
// Los efectos externos, DESPUÉS del commit y en el caso de uso
@Injectable()
export class TasksService {
async completar(id: number): Promise<void> {
const task = await this.em.findOneOrFail(Task, id, {
populate: ['assignee'],
});
task.completar();
await this.em.flush(); // transacción cerrada y confirmada
// Ahora sí: si esto falla, el estado en la BD ya es correcto
// y el reintento es responsabilidad de la cola.
await this.cola.encolar('task.completed', { id: task.id });
}
}
- Llamar a
em.flush(). Estás dentro de un vaciado. - E/S externa (HTTP, correo, colas, ficheros): alarga la transacción, bloquea filas y produce efectos irreversibles si luego hay rollback.
- Modificar otras entidades esperando que se persistan: los change sets ya están calculados. Para eso está
beforeFlushen un subscriber. - Asumir que las relaciones están cargadas.
- Meter reglas de negocio importantes. Un hook es invisible desde el caso de uso: quien lea el servicio no sabrá que existe.
14.10 Primer CRUD completo y comentado
@Injectable()
export class TasksService {
constructor(private readonly em: EntityManager) {}
// ─── CREATE ────────────────────────────────────────────────────────────
async crear(dto: CreateTaskDto): Promise<Task> {
// getReference no consulta: crea un proxy con la clave primaria.
// Suficiente para establecer la clave foránea.
const project = this.em.getReference(Project, dto.projectId);
// em.create() valida los tipos, aplica los valores por defecto y
// llama a persist() automáticamente (persistOnCreate: true).
const task = this.em.create(Task, {
title: dto.title,
description: dto.description,
project,
status: TaskStatus.PENDING,
});
await this.em.flush();
// BEGIN
// insert into "tasks" ("title", "description", "project_id", "status",
// "created_at", "version")
// values ($1, $2, $3, 'pending', $4, 1)
// returning "id", "created_at", "version"
// COMMIT
// ↑ RETURNING: PostgreSQL devuelve los valores generados en la MISMA
// ida y vuelta. En MySQL harían falta dos pasos.
return task; // ya tiene id, createdAt y version rellenos
}
// ─── READ (uno) ────────────────────────────────────────────────────────
async porId(id: number): Promise<Task> {
return this.em.findOneOrFail(Task, id, { populate: ['project'] });
// select "t0".*,
// ("t0"."due_date" < now() and "t0"."status" != 'done') as "overdue",
// "p1"."id" as "p1__id", "p1"."name" as "p1__name"
// from "tasks" as "t0"
// left join "projects" as "p1" on "t0"."project_id" = "p1"."id"
// where "t0"."id" = $1
// limit 1
// ↑ estrategia JOINED (la de por defecto en la v6 para SQL): una sola
// consulta. Con SELECT_IN serían dos: la tarea y luego el proyecto.
// ↑ @Formula viaja como expresión calculada en el SELECT.
// findOneOrFail lanza NotFoundError si no hay fila: evita el `if (!x) throw`.
}
// ─── READ (lista paginada) ─────────────────────────────────────────────
async listar(q: ListTasksQuery): Promise<{ items: Task[]; total: number }> {
const [items, total] = await this.em.findAndCount(
Task,
{ status: q.status, project: q.projectId },
{ limit: q.limit, offset: q.offset, orderBy: { createdAt: 'desc' } },
);
// select … from "tasks" as "t0"
// where "t0"."status" = $1 and "t0"."project_id" = $2
// order by "t0"."created_at" desc limit $3 offset $4
// select count(*) from "tasks" as "t0" where …
// ↑ dos consultas: los datos y el total. Ojo: `offset` grande es lento;
// en el capítulo 16 verás la paginación por cursor.
return { items, total };
}
// ─── UPDATE ────────────────────────────────────────────────────────────
async actualizar(id: number, dto: UpdateTaskDto): Promise<Task> {
const task = await this.em.findOneOrFail(Task, id);
// select … from "tasks" where "id" = $1 limit 1
// ↑ imprescindible para el change tracking: sin instantánea no hay diff.
this.em.assign(task, dto); // aplica solo los campos presentes en el DTO
await this.em.flush();
// BEGIN
// update "tasks"
// set "title" = $1, "updated_at" = $2, "version" = 2
// where "id" = $3 and "version" = 1
// COMMIT
// ↑ solo las columnas MODIFICADAS.
// ↑ `and version = 1` es el bloqueo optimista: si otra petición ya
// actualizó la fila, afecta a 0 filas y el ORM lanza
// OptimisticLockError en lugar de perder el cambio ajeno.
return task;
}
// ─── DELETE ────────────────────────────────────────────────────────────
async borrar(id: number): Promise<void> {
// Borrado sin SELECT previo: una referencia basta.
this.em.remove(this.em.getReference(Task, id));
await this.em.flush();
// BEGIN
// delete from "tasks" where "id" = $1
// COMMIT
// ↑ Sin cargar la entidad NO se ejecutan los hooks @BeforeDelete que
// dependan de sus datos, ni las cascadas que requieran el grafo
// cargado. Si los necesitas, carga la entidad primero.
}
// ─── Operación masiva: cuando NO quieres el Unit of Work ───────────────
async archivarAntiguas(antesDe: Date): Promise<number> {
// nativeUpdate NO pasa por el Unit of Work: no hidrata entidades,
// no lanza hooks y no actualiza el Identity Map. Es un UPDATE directo.
return this.em.nativeUpdate(
Task,
{ status: TaskStatus.DONE, updatedAt: { $lt: antesDe } },
{ archived: true },
);
// update "tasks" set "archived" = true
// where "status" = 'done' and "updated_at" < $1
// ↑ una sentencia para N filas. A cambio: si esas entidades ya estaban
// en el Identity Map, quedan DESACTUALIZADAS en memoria.
// Tras un nativeUpdate, considera em.clear() o em.refresh().
}
}
debug: ['query', 'query-params'] y mirar la consola. Haz eso con cada caso de uso que escribas: descubrirás relaciones cargadas sin querer, SELECT duplicados, N+1 y actualizaciones que no esperabas. Cuesta treinta segundos por endpoint y ahorra semanas de optimización a posteriori. 14.11 MikroORM frente a los demás ORM de Node
Ninguna de estas herramientas es «la mejor»: resuelven problemas distintos con compromisos distintos. Esta tabla intenta ser justa, incluso cuando eso significa reconocer que MikroORM no es la respuesta.
| Patrón | Tipado | Migraciones | Rendimiento | Curva | Madurez y comunidad | Ideal para | |
|---|---|---|---|---|---|---|---|
| MikroORM 6 | Data Mapper con Identity Map y Unit of Work reales | Excelente: inferencia de relaciones cargadas (Loaded<T>) y de carga parcial | Propias, con diff automático y snapshot | Muy bueno en escritura (agrupación y transacción implícita); coste de CPU en el change tracking | Alta: hay tres patrones que entender | Media-alta. Mantenimiento muy activo y de un rigor notable, pero comunidad bastante menor que Prisma o TypeORM | Backends con dominio rico, DDD, hexagonal, transacciones complejas. Encaje natural con NestJS |
| TypeORM | Los dos modos: Active Record y Data Mapper (sin Unit of Work de verdad) | Bueno, con huecos: any en varios puntos y relaciones poco estrechas | Propias; synchronize muy peligroso en producción | Aceptable; cada save() es un viaje | Baja | Alta adopción histórica; mantenimiento irregular durante años | Proyectos existentes y equipos que ya lo dominan |
| Prisma | Ninguno de los clásicos: cliente generado con API de consulta propia | El mejor del ecosistema: el cliente se genera desde el esquema, así que los resultados encajan al milímetro | prisma migrate, muy pulido, con drift detection | Muy bueno en lectura; durante años dependió de un motor en Rust distribuido como binario, algo que las versiones recientes están sustituyendo por una implementación en TypeScript | La más baja: prisma studio, autocompletado impecable y documentación ejemplar | La mayor con diferencia: comunidad, integraciones y material didáctico | Equipos que quieren productividad inmediata, CRUD y API sobre un esquema estable |
| Drizzle | Query builder tipado; sin patrones de persistencia | Excelente y muy directo: el SQL que escribes es el que se ejecuta | drizzle-kit, sencillo y explícito | Sobresaliente: sin capa de hidratación ni seguimiento; ligero y apto para edge y serverless | Baja si sabes SQL | Joven pero con crecimiento muy rápido | Servicios pequeños, edge functions, equipos con buen SQL que quieren control total |
| Sequelize | Active Record | Añadido después; el menos idiomático en TypeScript | Propias, basadas en umzug | Aceptable; el más antiguo y el que arrastra más decisiones heredadas | Baja | Muy alta por antigüedad (2011); enorme base instalada | Mantenimiento de sistemas heredados en JavaScript |
| Knex / Kysely | No son ORM: construyen SQL | Knex: flojo. Kysely: excelente, con tipos derivados del esquema | Knex incluye migraciones y seeds | Máximo: es SQL con azúcar | Muy baja si sabes SQL | Muy alta y estable; Knex es la base de muchas otras herramientas (los drivers SQL de MikroORM 6 lo usan internamente) | Informes, procesos ETL, y la capa de escape junto a cualquier ORM |
14.11.1 Lo que hacen mejor los demás
- Prisma tiene mejor experiencia de desarrollo, sin discusión. El esquema declarativo en un solo fichero, el cliente generado, el autocompletado y la calidad de la documentación no tienen rival. Si tu problema es «necesito una API sobre estas doce tablas y quiero estar en producción el viernes», Prisma es probablemente la respuesta correcta y defender MikroORM sería dogmatismo. Sus límites reales aparecen con dominios ricos: no hay Identity Map ni Unit of Work, así que la coordinación de escrituras complejas la orquestas tú, y el modelo de datos vive en un lenguaje propio en lugar de en tus clases, lo que dificulta las entidades con comportamiento.
- Drizzle es más rápido y más ligero. No hay hidratación de entidades, ni instantáneas, ni metadatos que descubrir: eso se nota en el arranque en frío y en el consumo de memoria, dos cosas críticas en serverless y en el edge. Su API es tan cercana al SQL que casi no hay traducción mental. Lo que no te da es precisamente lo que enseña este capítulo: si escribes un caso de uso que toca ocho entidades relacionadas, el orden de inserción, la transacción y la coherencia en memoria son tu responsabilidad.
- Knex y Kysely son insustituibles para informes y procesos por lotes, incluso teniendo un ORM. No compiten: complementan.
- TypeORM y Sequelize tienen más material didáctico y más respuestas en foros, simplemente por antigüedad. Con un equipo junior y sin tiempo de formación, eso pesa.
14.12 Errores comunes y cómo solucionarlos
| Síntoma o error | Causa real | Solución |
|---|---|---|
Using global EntityManager instance methods for context specific actions is disallowed | Código que usa orm.em fuera de una petición: cron, consumidor de cola, onModuleInit, script | @CreateRequestContext() (o @EnsureRequestContext()) en el método de entrada, o un orm.em.fork() explícito. allowGlobalContext: true solo en tests |
| La petición responde 200 pero la base de datos no cambia | Falta await em.flush(), o el flush() está en una rama que no se ejecuta | Un flush() al final de cada caso de uso que escriba. Un test de integración que compruebe el efecto lo detecta a la primera |
task.id es undefined justo después de crear la entidad | Se espera que persist() escriba. No escribe: solo apunta | await em.flush() antes de leer la clave generada |
| Los cambios sobre una entidad no se guardan y no hay ningún error | La entidad está separada: viene de otro contexto, de una caché, de un em.clear() o de disableIdentityMap: true | Recargarla en el contexto actual con em.findOne(). Guarda identificadores entre contextos, nunca entidades |
MetadataError: No entities were discovered o «entity not found» | Rutas de entities mal configuradas: apuntan a src/**/*.ts en producción, o falta entitiesTs en desarrollo | Importación explícita de clases (lo más robusto), o mantener las dos listas y comprobarlo con npx mikro-orm debug |
| Tipo de columna inesperado, o error de metadatos solo en producción | Metadata provider incorrecto: TsMorphMetadataProvider sin fuentes .ts ni .d.ts en la imagen, o falta emitDecoratorMetadata con ReflectMetadataProvider | Usar ReflectMetadataProvider y declarar type explícito en los casos ambiguos; si usas ts-morph, generar la caché en el build con cache:generate |
Cannot read properties of undefined (reading 'name') al navegar una relación | La relación es una referencia sin inicializar; nadie la cargó | populate en la consulta, em.populate() después, o declararla con ref: true para que el tipo obligue a load() |
| Iterar una colección lanza un error | Colección no inicializada. Desde la v6 esto falla de forma explícita en lugar de devolver algo vacío | populate de la colección, o await collection.init() |
Memoria creciente y JavaScript heap out of memory en un script o worker | El Identity Map acumula todas las entidades cargadas más sus instantáneas | Procesar por lotes con flush() y em.clear() en cada iteración; para solo lectura, carga parcial o disableIdentityMap: true |
Aparece un INSERT donde no había ningún flush() | FlushMode.AUTO: el ORM vacía antes de una consulta que podría verse afectada por los cambios pendientes | Es correcto. Si necesitas control estricto, FlushMode.COMMIT dentro de em.transactional() |
| Entidades desactualizadas en memoria tras una operación masiva | nativeUpdate/nativeDelete no pasan por el Unit of Work y no actualizan el Identity Map | em.refresh(entity) para una, em.clear() para todas; o hacer la operación masiva en un fork aparte |
| Un endpoint devuelve más campos que otro para el mismo recurso | Identity Map compartido: la información de «qué está poblado» viaja con la instancia | Un EM por petición (nunca allowGlobalContext en producción) y devolver DTOs, no entidades |
14.13 Buenas y malas prácticas
Haz esto
- Un
EntityManagerpor unidad de trabajo y un soloflush()al final del caso de uso. - Mira el SQL generado de cada endpoint que escribas, con
debugactivo en desarrollo. - Devuelve DTOs, no entidades, desde los controladores: evitas fugas de campos, ciclos al serializar y acoplar tu API al esquema.
- Deja las entidades libres de E/S: métodos que cambian estado y validan invariantes, nada más.
findOneOrFailen lugar defindOnemásif: menos ruido y un error coherente.getReferencepara asignar relaciones y para borrar: evita unSELECTinútil.- Migraciones siempre, revisadas en el pull request como cualquier otro código.
em.clear()entre lotes en cualquier proceso largo.- Bloqueo optimista (
version: true) en las entidades que varios usuarios editan a la vez. - Efectos externos después del
commit, nunca dentro de un hook. - UUID v7 si el identificador se expone; entero autoincremental si no.
- Importación explícita de entidades en la configuración: falla al compilar, no en producción.
Evita esto
allowGlobalContext: trueen la aplicación. Silencia un aviso y abre una fuga de datos entre usuarios.persistAndFlushdentro de un bucle: N transacciones donde debería haber una.- Mutar entidades gestionadas «para calcular»: cualquier cambio es un
UPDATEprogramado. - Guardar entidades en cachés, variables de módulo o mensajes de cola: quedan separadas y sus cambios se pierden en silencio.
- Lógica de negocio en hooks: es invisible desde el caso de uso y muy difícil de depurar.
- Llamadas HTTP o de correo dentro de la transacción.
schema:update --runen producción.em.merge()como remedio para entidades separadas, sin entender que estás afirmando que esos datos son los de la base de datos.- Cargar colecciones completas para contar: usa
em.count()ocollection.loadCount(). numberparadecimal: los importes monetarios pierden precisión.- Exponer entidades con
JSON.stringifyconfiando enhiddencomo si fuera un control de acceso. - Cambiar la naming strategy cuando ya hay datos en producción.
14.14 Preguntas frecuentes
Explica Data Mapper frente a Active Record en una frase para cada uno
new Task() y sin base de datos.¿Qué problema resuelve el Identity Map y qué NO resuelve?
UPDATE que se sobrescriban entre sí. Como efecto secundario, ahorra la consulta cuando pides por clave primaria algo ya cargado. Lo que no es: un caché de consultas. Si buscas por cualquier otro criterio, el SELECT se ejecuta siempre; lo único que se garantiza es que la fila ya conocida se devuelve como la instancia existente. Tampoco se comparte entre peticiones ni sobrevive a un em.clear().¿Por qué persist() no escribe nada en la base de datos?
persist() registra la intención y flush() la ejecuta. Separarlos es lo que permite agrupar todas las escrituras del caso de uso en una sola transacción, ordenarlas según las dependencias de claves foráneas y agrupar sentencias por lotes. Si persist() escribiera, tendrías Active Record con otro nombre y perderías la atomicidad.Si modifico una entidad cargada, ¿tengo que llamar a persist()?
flush(), el ORM compara la entidad con esa instantánea y genera el UPDATE con los campos que cambiaron. persist() solo hace falta para entidades nuevas creadas con new, y ni eso si cuelgan del grafo de una entidad gestionada con cascada.¿Cómo detecta MikroORM los cambios, si no usa proxies ni setters?
em.getUnitOfWork().getOriginalEntityData(entity)). En el flush() compara propiedad a propiedad. El coste es una comparación por entidad gestionada en cada vaciado, que es despreciable con decenas de entidades y perceptible con decenas de miles; la ventaja es que tus entidades siguen siendo objetos normales, sin instrumentación.¿Por qué no puedo compartir el EntityManager entre peticiones?
flush() de una petición escribiría los cambios a medias de otra. Por eso el ORM lo prohíbe de forma explícita y ofrece RequestContext.¿Qué es exactamente RequestContext y qué relación tiene con AsyncLocalStorage?
RequestContext.create(orm.em, next) hace un fork() del EM global y lo guarda en un AsyncLocalStorage del núcleo de Node, que propaga ese valor por toda la cadena de await de la petición. Después, cualquier método del EM global llama internamente a em.getContext(), que recupera el fork del almacén. Gracias a eso puedes inyectar el EM global como un singleton en Nest y obtener, en cada llamada, el EM aislado de la petición actual sin pasar parámetros por todas las capas.¿Cuál es la diferencia entre em.clear(), em.fork() y em.refresh()?
em.clear() vacía el Identity Map y la cola de cambios del EM actual: todas sus entidades quedan separadas y los cambios no vaciados se pierden. em.fork() crea un EM nuevo con su propio Identity Map, sin tocar el original, compartiendo configuración y pool. em.refresh(entity) es el único de los tres que ejecuta SQL: recarga esa entidad desde la base de datos, descarta sus cambios locales y actualiza su instantánea.¿Qué es un EntityRepository en MikroORM? ¿Es un patrón Repository de DDD?
EntityRepository<T> es una fachada tipada sobre el mismo EntityManager: guarda el nombre de la entidad y reenvía las llamadas. No es una abstracción que aísle tu dominio del ORM ni tiene un contexto propio; de hecho, en la v6 se le quitaron los métodos de persistencia precisamente porque daban esa impresión falsa. El Repository de DDD es una interfaz definida en tu dominio con métodos del lenguaje del negocio, implementada en infraestructura. Puedes construir ese Repository usando un EntityRepository como detalle interno.¿Cuándo conviene un flush() intermedio en lugar de uno solo al final?
em.transactional() para no perder la atomicidad. El caso legítimo es necesitar un valor generado por la base de datos para una lógica que no sea una simple asignación de relación (por ejemplo, calcular un código a partir del id autoincremental). Para las relaciones no hace falta: el Unit of Work ordena los INSERT y propaga las claves por ti. Y si el motivo es «procesar un millón de filas», la respuesta correcta es lotes con flush() más em.clear().¿En qué orden ejecuta el flush() las sentencias y por qué importa?
¿MikroORM me libra de aprender SQL?
¿Qué cambia de la v5 a la v6 que me vaya a encontrar en tutoriales antiguos?
type: 'postgresql' se sustituye por defineConfig() importado del paquete del driver (o por la opción driver); IdentifiedReference pasa a ser Ref y wrappedReference a ref: true; onDelete/onUpdate de las relaciones se llaman deleteRule/updateRule; @UseRequestContext() pasa a @CreateRequestContext(); los repositorios pierden persist, flush y compañía; las extensiones (Migrator, SeedManager) se declaran en extensions; el decorador @Subscriber() desaparece en favor de subscribers en la configuración; los fragmentos de SQL exigen el helper raw(); la opción cache se llama metadataCache; PrimaryKeyType pasa a PrimaryKeyProp con tuplas; y la estrategia de carga por defecto en SQL pasa a ser joined.¿Es el Identity Map una caché que deba preocuparme por invalidar?
nativeUpdate; ahí tienes em.refresh().14.15 Ejercicios
14.1 Arranca un proyecto con MikroORM 6, PostgreSQL en Docker y dos entidades: Project (id, name, active) y Task (id, title, status, createdAt, project). Crea el esquema con el CLI y comprueba con npx mikro-orm debug que descubre las dos entidades.
14.2 Con debug: ['query', 'query-params'] activo, escribe un script que haga dos em.findOne(Task, 1) seguidos y otro em.findOne(Task, { title: '…' }) de la misma fila. Anota cuántas consultas se ejecutan y qué devuelve === en cada combinación. Explica por escrito la diferencia.
14.3 Crea una tarea con new Task() y em.persist(), imprime task.id antes y después del flush(), y explica el resultado. Repítelo con em.create() sin llamar a persist(): ¿se guarda? ¿Por qué?
14.4 Escribe un caso de uso que cree un proyecto y tres tareas suyas con un único flush(). Observa el SQL: ¿en qué orden se insertan? ¿Se agrupan las tareas en una sola sentencia? Ahora hazlo con un persistAndFlush por entidad y compara el número de transacciones.
14.5 Provoca a propósito el error «Using global EntityManager instance methods for context specific actions is disallowed» desde un método que no sea un manejador HTTP. Arréglalo de las dos formas: con @CreateRequestContext() y con un fork() explícito. Explica qué haría allowGlobalContext: true y por qué no es una solución.
14.6 Escribe un script que recorra 100 000 tareas por lotes de 500 y mida la memoria con process.memoryUsage().heapUsed al final de cada lote. Ejecútalo con y sin em.clear() y compara las dos curvas.
14.7 Demuestra el estado separado: carga una tarea, llama a em.clear(), cámbiale el título, haz flush() y comprueba en la base de datos que no ha pasado nada. Después consigue el mismo efecto de forma realista pasando la entidad a un fork().
14.8 Añade @Property({ version: true }) a Task y escribe un test que simule dos ediciones concurrentes con dos forks. Comprueba que la segunda falla y captura el error de bloqueo optimista.
14.9 Implementa un EventSubscriber de auditoría que, para cada UPDATE, inserte una fila en audit_log con la entidad, la clave primaria, los campos modificados y sus valores anterior y nuevo. Debe funcionar en el mismo flush() y dentro de la misma transacción. Justifica por qué usas beforeFlush y no @AfterUpdate.
14.10 Define la misma entidad Task de dos formas: con decoradores y con EntitySchema sobre una clase de dominio sin ninguna importación de MikroORM. Comprueba que las dos generan el mismo DDL con schema:create --dump y añade una regla de ESLint que prohíba importar @mikro-orm/* desde la carpeta del dominio.
14.11 Mide el impacto del Identity Map: carga 50 000 entidades y cronometra un flush() sin cambios; repítelo con disableIdentityMap: true y con carga parcial (fields). Presenta una tabla con tiempo y memoria, y una recomendación razonada para un endpoint de listado.
14.12 Escribe un servicio con un método que se invoque tanto desde un controlador como desde un consumidor de cola. Consigue que funcione en los dos casos sin duplicar código y sin anidar contextos. Explica por qué @EnsureRequestContext() es aquí mejor que @CreateRequestContext().
Solución comentada del ejercicio 14.4 · un flush frente a muchos
// ── CORRECTO: un solo flush ────────────────────────────────────────────────
async crearProyectoConTareas(nombre: string, titulos: string[]) {
const project = this.em.create(Project, { name: nombre, active: true });
for (const title of titulos) {
// No necesitamos el id del proyecto: pasamos la REFERENCIA al objeto.
// El Unit of Work insertará primero el proyecto y propagará su clave.
this.em.create(Task, { title, project, status: TaskStatus.PENDING });
}
await this.em.flush();
}
SQL observado (PostgreSQL). Una sola transacción con dos sentencias:
begin;
insert into "projects" ("name", "active") values ('Libro', true) returning "id";
insert into "tasks" ("title", "status", "project_id", "created_at") values
('Cap. 14', 'pending', 3, now()),
('Cap. 15', 'pending', 3, now()),
('Cap. 16', 'pending', 3, now())
returning "id";
commit;
Aquí se ven los dos patrones en acción a la vez. El orden de commit resuelve la dependencia: el proyecto se inserta antes que las tareas, aunque en el código se crearan de forma intercalada, y su id generado se propaga a la clave foránea sin que tú lo pidas. El batching convierte tres inserciones en una sola sentencia con tres tuplas.
La variante con persistAndFlush por entidad produce cuatro transacciones y cuatro INSERT. Con 1 ms de latencia de red, la diferencia es de unos 8 ms frente a 2 ms; con cien tareas, de unos 200 ms frente a 3 ms. Pero el problema grave no es la velocidad: si la tercera tarea viola una restricción, en la versión con muchos flush el proyecto y dos tareas ya están confirmados en la base de datos y no hay forma limpia de deshacerlo. La versión con un solo flush() hace ROLLBACK y deja el sistema exactamente como estaba.
Solución comentada del ejercicio 14.6 · memoria constante en un proceso largo
async recorrerTodo(): Promise<void> {
// 1) Contexto propio: no contaminamos ni heredamos el de nadie
const em = this.orm.em.fork();
const TAM = 500;
let offset = 0;
for (;;) {
// 2) orderBy estable: sin un orden determinista, la paginación por
// offset puede repetir u omitir filas si hay escrituras concurrentes
const lote = await em.find(Task, {}, {
limit: TAM,
offset,
orderBy: { id: 'asc' },
});
if (lote.length === 0) break;
for (const t of lote) {
t.slug = slugify(t.title); // cambio detectado por el change tracking
}
// 3) Primero persistir...
await em.flush();
// 4) ...y SOLO DESPUÉS liberar. Al revés, los cambios se perderían
// en silencio: em.clear() no avisa de que había trabajo pendiente.
em.clear();
offset += TAM;
// 5) Opcional: ceder el event loop para no monopolizar el proceso
await new Promise((r) => setImmediate(r));
}
}
Resultado esperado. Sin em.clear(), heapUsed crece de forma monótona y lineal con el número de filas procesadas, porque cada entidad queda referenciada por el Identity Map y por su instantánea original: el recolector de basura no puede liberar nada. Con em.clear(), la memoria dibuja una sierra que se mantiene estable lote tras lote.
Detalle que suele fallar en las entrevistas: el problema no lo causa find() devolviendo muchos objetos —esos se podrían liberar—, sino que el Unit of Work los retiene para poder detectar cambios. Es el precio del patrón, y em.clear() es el mecanismo previsto para pagarlo solo cuando hace falta. Para recorridos de solo lectura, la alternativa mejor es no crear entidades gestionadas: carga parcial con fields, disableIdentityMap: true o directamente SQL con em.getConnection().execute().
14.16 Resumen del capítulo
- El desajuste objeto-relacional es real y no desaparece. Identidad, granularidad, herencia, navegación y tipos son incompatibles entre objetos y tablas; un ORM traduce, no elimina el problema. No te libra de saber SQL, de pensar índices ni de decidir transacciones.
- Data Mapper: la entidad no sabe que existe una base de datos y un mapeador externo traduce. Frente a Active Record gana en pureza del dominio, testabilidad y control del SQL; pierde en brevedad inmediata. Es lo que encaja con DDD y con la arquitectura hexagonal.
- Identity Map: una fila, un objeto por contexto. Evita consultas repetidas cuando buscas por clave primaria, garantiza coherencia en memoria y es la «caché de primer nivel». Crece de forma monótona: en procesos largos,
em.clear(). - Unit of Work:
persist()apunta,flush()ejecuta. El vaciado calcula los cambios contra las instantáneas originales, ordena las operaciones topológicamente, abre una transacción, agrupa las sentencias, dispara los eventos, confirma y sincroniza el estado en memoria. - Cuatro estados: transitoria, gestionada, eliminada y separada. Modificar una entidad separada no produce ningún error y no guarda nada: es el fallo silencioso más frustrante del ORM.
- Un EntityManager por unidad de trabajo, sin excepciones. Compartirlo es a la vez una fuga de memoria y una fuga de datos entre usuarios.
RequestContextlo resuelve conAsyncLocalStorage; fuera de una petición,@CreateRequestContext()ofork(). - El
EntityRepositoryes una fachada tipada sobre el EM, no una abstracción de persistencia. El punto de extensión útil es el repositorio personalizado. - En la v6:
defineConfig()desde el paquete del driver, extensiones explícitas,Ref,deleteRule/updateRule,@CreateRequestContext()y estrategia de carga joined por defecto. - Elige con criterio, no por bando. Prisma gana en experiencia de desarrollo, Drizzle en latencia y ligereza, Knex y Kysely en informes. MikroORM gana cuando el valor está en el dominio y en la consistencia transaccional.
- Mira siempre el SQL. Es el hábito que separa a quien usa un ORM de quien lo sufre.
14.17 Recursos adicionales
- MikroORM · Identity Map and Request Context — la referencia oficial de este capítulo, incluidos los dos problemas del EM compartido.
- MikroORM · Unit of Work and Transactions — detección de cambios, transacciones implícitas y modos de vaciado.
- MikroORM · Entity Manager — la API completa que hemos recorrido.
- MikroORM · Configuration — todas las opciones, con sus valores por defecto por versión.
- MikroORM · Defining Entities — decoradores,
EntitySchemay opciones de propiedad. - MikroORM · Upgrading from v5 to v6 — imprescindible para interpretar tutoriales antiguos.
- MikroORM · Usage with NestJS — el módulo, el middleware de contexto y
@InjectRepository. - Martin Fowler · Catálogo de PoEAA — las fichas originales de Data Mapper, Identity Map, Unit of Work y Active Record; el libro Patterns of Enterprise Application Architecture (2002) sigue siendo la mejor lectura sobre esto.
- Doctrine ORM · Unit of Work — la documentación en la que se inspira MikroORM; útil cuando busques comportamientos internos.
- Node.js · AsyncLocalStorage — el mecanismo que hace posible
RequestContext. - MikroORM · Notas de versión — la fuente fiable para saber en qué versión entró cada cosa.