Parte IV · MikroORM

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.

COREMIKROORM Tiempo de lectura: ~100 min Prerrequisitos: capítulos 1 y 8; SQL básico

14.1 Qué vas a poder hacer al terminar

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ónEn objetosEn tablasConsecuencia práctica
IdentidadDos variables son «el mismo objeto» si comparten referencia (===)Dos filas son la misma si comparten clave primariaSi el ORM no lleva la cuenta, la misma fila puede acabar como dos objetos distintos e incoherentes en memoria. Lo resuelve el Identity Map.
GranularidadPuedes crear clases pequeñas y expresivas: Dinero, Email, DireccionCrear una tabla por cada concepto pequeño es caro y poco prácticoNecesitas embeddables y tipos personalizados para que varias propiedades vivan en columnas de la misma tabla (capítulo 15).
Herencia y polimorfismoNatural: class Factura extends DocumentoNo existe. Hay que emularlaTres estrategias imperfectas: tabla única con discriminador, tabla por clase o tabla por jerarquía. Cada una sacrifica algo.
Asociaciones y navegaciónReferencias direccionales que se recorren con un punto: task.project.owner.emailClaves foráneas simétricas que se recorren con JOINNavegar sin pensar produce el problema N+1 (capítulo 16). Además, la bidireccionalidad hay que mantenerla a mano en memoria.
Tipos de datosDate, bigint, enum, arrays, JSON, clases de valorUn conjunto cerrado de tipos por motor, con precisión y zona horaria propiasConversiones en los dos sentidos, y sorpresas clásicas: bigint que llega como cadena, DECIMAL que no es un number seguro, fechas sin zona horaria.
Analogía Un ORM es un intérprete simultáneo entre dos idiomas con gramáticas distintas. Traduce muy bien las frases habituales, pero hay expresiones que no tienen equivalente exacto y otras que, traducidas literalmente, suenan absurdas. El intérprete no te exime de conocer el idioma de destino: te exime de hablarlo todo el rato.

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 JOIN de 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).
La regla que ahorra años de disgustos Activa el registro de consultas en desarrollo (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.

EscenarioPor qué el ORM estorbaQué usar en su lugar
Informes y analítica: agregaciones sobre millones de filas, GROUP BY con funciones de ventana, pivotadosNo necesitas objetos ni identidad ni seguimiento de cambios: necesitas filas planas. Hidratar entidades es puro desperdicio de CPU y memoriaSQL 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 registrosEl Identity Map crecería sin límite y el cálculo de diferencias multiplicaría el coste por filaCOPY 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, PostGISExpresarlas a través del ORM es más difícil de leer que el propio SQLSQL 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 usoConfigurar metadatos y descubrimiento para tres consultas no compensaUn query builder ligero como Knex o Kysely, o el cliente del driver directamente
Servicios diminutos con dos tablas y sin lógica de dominioLa curva de aprendizaje del equipo supera el ahorroKysely o Drizzle: tipado fuerte, sin patrones de persistencia que aprender
No es «todo o nada» Lo profesional es convivir: entidades y Unit of Work para los casos de uso transaccionales del dominio, y SQL crudo o QueryBuilder para los informes y los procesos masivos, todo sobre la misma conexión y el mismo pool. MikroORM lo permite explícitamente; usar las dos vías no es una derrota, es diseño.

14.3 Historia y contexto: de Fowler a MikroORM 6

Cronología esencial

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.

Por qué te interesa esta genealogía Cuando busques ayuda para un comportamiento del Unit of Work y no encuentres nada en la documentación de MikroORM, busca «Doctrine unit of work» o «Hibernate flush order»: la semántica es prácticamente la misma porque el linaje es directo. Es uno de los mejores atajos de aprendizaje que existen en este ecosistema.

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».

estilo Active RecordACTIVE RECORD
// 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();
estilo Data Mapper (MikroORM)DATA MAPPER
// 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

CriterioActive RecordData Mapper
AcoplamientoAlto: la entidad depende del ORM y, a través de él, de la base de datosBajo: la entidad es un objeto normal; el ORM depende de ella, no al revés
TestabilidadRegular: casi todo test toca la persistencia; se acaba usando SQLite en memoria para todoBuena: el dominio se prueba sin E/S; solo los repositorios y casos de uso necesitan base de datos
Pureza del dominioNula por diseño: una clase con estado, reglas y SQL viola la responsabilidad únicaAlta: separación explícita entre modelo y persistencia
Ergonomía inmediataExcelente: user.save() es difícil de superar en brevedadAlgo más ceremoniosa: hay que inyectar el EM y acordarse de flush()
Control del SQLCada save() es un viaje. Muchos objetos, muchas sentenciasUn flush() agrupa todo el caso de uso en una transacción con sentencias por lotes
Curva de aprendizajeBaja al principio; sube cuando aparecen transacciones y consistenciaMás alta al principio (hay tres patrones que entender); estable después
Encaje con DDD y hexagonalMalo: el dominio arrastra la infraestructuraNatural: es prácticamente el diagrama del patrón
Quién lo usaRails ActiveRecord, Laravel Eloquent, Django ORM, Sequelize, TypeORM en modo Active RecordHibernate/JPA, Doctrine 2, MikroORM, TypeORM en modo Data Mapper, Entity Framework
Matiz importante sobre TypeORM TypeORM soporta los dos modos: heredando de 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:

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:

sin-identity-map.ts · qué pasaría en un ORM sin este patrónINCORRECTO
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

identity-map.spec.ts · el === que sorprende a todo el mundo
const 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.
La distinción que hay que memorizar El Identity Map solo evita la consulta cuando buscas por clave primaria (y esa clave ya está cargada). Si buscas por cualquier otra condición, la consulta se ejecuta siempre —el ORM no puede saber si la fila cambió ni si hay filas nuevas que cumplan la condición—, pero la identidad se respeta igualmente: la fila ya conocida se devuelve como la instancia existente. No es un caché de consultas; es un registro de identidades. Para cachear resultados de consultas está resultCache.

14.5.4 Efectos: caché de primer nivel, memoria y em.clear()

export.job.tsINCORRECTO
// 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
  }
}
export.job.tsCORRECTO
// 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.

Analogía Es la diferencia entre ir al supermercado ocho veces (una por producto) y hacer una lista y una sola compra. Cada viaje a la base de datos cuesta latencia de red, planificación de la consulta y una transacción; agrupar reduce el coste y, sobre todo, hace que el resultado sea todo o nada: no puedes volver a casa con la mitad de la cena.

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.

change-tracking.ts
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
Consecuencia número uno: mutar una entidad gestionada YA es una escritura pendiente

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                                                │
  └─────────────────────────────────────────────────────────────────────┘
Lo que te da gratis el paso 2 El orden de commit es una de esas cosas que no valoras hasta que has escrito SQL a mano: si creas un proyecto y tres tareas que lo referencian, alguien tiene que insertar el proyecto primero y propagar su 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()

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().

tasks.service.tsINCORRECTO
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.
}
tasks.service.tsCORRECTO
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
}
Un solo 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
EstadoQué 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 existeNonew Task()
Gestionada (managed)Está en el Identity Map de un EM concreto y tiene instantánea original. Cualquier cambio que le hagas se detectará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 mapaSí (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()Noem.clear(), fin de la petición, entidad serializada y devuelta a un nuevo contexto, disableIdentityMap: true
El error silencioso más frustrante del ORM Modificar una entidad separada y llamar a 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 exactamenteCuándo usarlo
em.persist(e)NoMarca 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 encadenarSiempre que crees entidades con new
em.remove(e)NoMarca para borrado. La entidad sigue accesible hasta el vaciadoBorrados; recuerda que necesita la entidad cargada o una referencia
em.flush()Ejecuta los seis pasos de la sección 14.6.2. Si no hay cambios, no abre transacción ni envía nadaUna vez, al final del caso de uso
em.persistAndFlush(e)Atajo de los dos anterioresScripts, seeders y tests; en servicios, prefiere separarlos
em.refresh(e)SELECT que recarga la entidad desde la base de datos y descarta los cambios locales, actualizando también la instantáneaTras un UPDATE nativo, o cuando sospechas que otro proceso cambió la fila
em.clear()NoVacía Identity Map y cola de cambios: todo pasa a separado. Los cambios sin vaciar se pierdenEntre lotes de un proceso largo, siempre después de flush()
em.merge(e)NoRegistra 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 ORMMuy raro: hidratar desde una caché externa o desde un mensaje. Casi siempre es preferible volver a cargar con em.find*()
em.transactional(cb)Abre transacción explícita, ejecuta la función con un EM propio, hace flush() y COMMIT; ante una excepción, ROLLBACKCuando necesitas varios vaciados atómicos o control del nivel de aislamiento (capítulo 17)
em.getUnitOfWork()NoAcceso 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.

ModoComportamientoComentario
FlushMode.AUTOPor 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.COMMITRetrasa el vaciado hasta el commit de la transacciónMás predecible, a costa de que una consulta intermedia no vea tus cambios en memoria
FlushMode.ALWAYSVacía antes de cada consultaMá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 INSERT y 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 UPDATE no 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.

Caso real: la respuesta que se contamina

Una API con dos endpoints sobre la misma entidad y un único EM global:

  1. GET /invoices/42 devuelve la factura sin relaciones. Responde { id: 42, total: 100, customer: 7 }. Correcto.
  2. Otro usuario, con permisos de administración, llama a GET /invoices/42?include=customer. El servicio hace populate: ['customer'] y devuelve la factura con el cliente completo, incluidos su NIF y su dirección. Correcto para ese usuario.
  3. El primer usuario recarga GET /invoices/42. Como la instancia sigue en el Identity Map compartido y ahora tiene la relación customer marcada 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.

reports.cron.tsINCORRECTO
@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.
reports.cron.tsCORRECTO
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 conexionesSe comparten (no se duplican)
Identity MapNo: el fork nace vacío (opción clear: true, por defecto)
Cola de cambios del Unit of WorkNo: nace vacía
Transacción activaNo: el fork empieza sin transacción
Filtros globales y sus parámetrosSí, se heredan
flushMode y schema activoSí, se heredan (se pueden sobrescribir en las opciones)
Gestor de eventos y suscriptoresSe comparte, salvo freshEventManager: true
Resolución del contexto asíncronoNo por defecto: el fork es independiente. Con useContext: true pasa a respetar el RequestContext
Un fork es barato, pero no es gratis olvidarse de las entidades Las entidades de un EM no se pueden usar en otro. Si pasas una entidad cargada en el EM de la petición a un fork del worker, allí está separada. Pasa identificadores entre contextos y recarga en cada uno; es el mismo principio que aplicamos a las colas de mensajes en el capítulo 11.

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
app.module.ts · el cableado en NestJS (v6)
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 {}
tasks.service.ts · el EM inyectado es siempre el correcto
@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
  }
}
Dónde falla esto en la práctica
  • 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 un fork() explícito.
  • Callbacks que escapan del contexto: setTimeout, setInterval o un .then() sin await lanzados 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 variable MIKRO_ORM_ALLOW_GLOBAL_CONTEXT) para no llenar los tests de fork(). 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.

repositorio.ts · las dos líneas son equivalentes
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ónQué usarPor qué
Consultas variadas dentro de un caso de uso, y persistenciaEntityManagerEs el dueño del Unit of Work; evita inyectar seis repositorios en un servicio
Un servicio centrado en una sola entidad, con muchas consultasEntityRepository<T>Ahorra repetir el tipo en cada llamada y se lee mejor
Consultas de negocio recurrentes y con nombre propioRepositorio personalizadoEs el punto de extensión previsto: encapsula QueryBuilder y filtros bajo un nombre del dominio
Aislar el dominio de MikroORM por completoInterfaz de repositorio en el dominio y un adaptador en infraestructuraEs el puerto de la arquitectura hexagonal; nada que ver con EntityRepository (capítulo 20)
task.repository.ts · repositorio personalizadoPATRÓN RECOMENDADO
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

terminal · instalación en un proyecto NestJS con PostgreSQL
# 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

PaqueteClase de driverDependenciaParticularidades
@mikro-orm/postgresqlPostgreSqlDriverpgLa 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/mysqlMySqlDrivermysql2Sin RETURNING: los id generados se recuperan aparte. Cuidado con la colación y con utf8mb4
@mikro-orm/mariadbMariaDbDrivermariadbMuy similar a MySQL; el JSON se maneja de forma distinta
@mikro-orm/sqliteSqliteDriversqlite3Ideal para tests (:memory:). Tipado laxo, sin ALTER completo: las migraciones recrean tablas
@mikro-orm/better-sqliteBetterSqliteDriverbetter-sqlite3Misma semántica, API nativa síncrona y notablemente más rápida en tests
@mikro-orm/libsqlLibSqlDriverlibsqlCompatible con SQLite; permite bases remotas al estilo Turso
@mikro-orm/mssqlMsSqlDrivertediousAñadido durante la serie 6.x. Particularidades de IDENTITY, OFFSET/FETCH y colaciones
@mikro-orm/mongodbMongoDrivermongodbSin QueryBuilder SQL ni migraciones de esquema; _id de tipo ObjectId; transacciones solo con replica set
La misma API con distintos motores no significa portabilidad automática Puedes desarrollar sobre SQLite y desplegar en PostgreSQL, pero no deberías: los tipos de columna, las restricciones, el comportamiento de las transacciones y el tratamiento de 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

src/mikro-orm.config.ts
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ónPara qué sirveRecomendación
entities / entitiesTsClases o rutas donde buscar entidadesImportació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
metadataProviderDe dónde salen los tipos de las propiedadesVer la tabla de 14.8.4
debugtrue o lista de espacios: query, query-params, discovery, infoActivo en desarrollo, desactivado en producción (los parámetros pueden contener datos personales)
loggerFunción que recibe cada mensaje del ORMConéctala a tu logger estructurado (capítulo 13) para no perder trazas
poolTamaño y tiempos de espera del poolDimensiona con la fórmula «instancias × max ≤ conexiones del motor»; con serverless, usa un pooler
driverOptionsOpciones crudas del cliente (SSL, timezone, keepalive)Última salida cuando el ORM no expone algo; se fusiona en profundidad
schemaEsquema por defecto; base de la multi-tenencia por esquemaExplícito siempre; en PostgreSQL no dependas del search_path
namingStrategyUnderscoreNamingStrategy (SQL), EntityCaseNamingStrategy (sin cambios), MongoNamingStrategyDecídelo antes de la primera migración: cambiarlo después renombra todas las columnas
forceUtcTimezoneGuarda los Date en UTC en columnas sin zonaActívalo. Guardar en hora local es una fuente inagotable de errores
validate / strictValidación de tipos de propiedad antes de persistir, y si se convierten o se lanza errorAmbas a true en desarrollo; en producción mide el coste antes de dejarlas
resultCacheCaché de resultados de consulta (expiration, adapter, global)Activación por consulta, nunca global «por si acaso»; con varias instancias, adaptador en Redis
allowGlobalContextDesactiva la comprobación del contextofalse 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.

ReflectMetadataProviderTsMorphMetadataProvider
De dónde saca los tiposDe los metadatos que emite el compilador con emitDecoratorMetadataAnaliza el código fuente TypeScript (o los .d.ts) con ts-morph
PaqueteIncluido en el núcleo (por defecto)@mikro-orm/reflection
ArranqueInmediatoLento la primera vez (analiza los ficheros); imprescindible la caché de metadatos
Tipos que no deduceUniones, opcionales complejos, Ref<T>, genéricos: hay que declarar type a mano en el decoradorPrácticamente todos, incluidos los envueltos y los opcionales
Requisitos de tsconfigexperimentalDecorators y emitDecoratorMetadataNecesita las fuentes .ts o los .d.ts en el despliegue
Compatibilidad con SWC, esbuild, ViteBuena (con el plugin de decoradores correspondiente)Problemática: si el bundle no lleva las fuentes, falla en producción
Cuándo elegirloPor defecto. Es la opción segura y la más común hoyCuando quieras entidades sin anotar tipos a mano y controles el proceso de despliegue
El error de despliegue clásico Con 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

package.json
{
  "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"
    ]
  }
}
terminal · comandos que usarás a diario
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

src/config/orm.config.ts · una base y variantes
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,
});
test/orm.setup.ts · patrón de aislamiento entre tests
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
});
Tres niveles de test para la capa de persistencia 1) Dominio: entidades con 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

src/tasks/task.entity.ts
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 @PropertyQué haceNota práctica
typeTipo 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
columnTypeTipo de columna literal del motor ('timestamptz(3)', 'jsonb')Escotilla de escape: se escribe tal cual en el DDL, sin portabilidad
nullablePermite NULLDebe ir de la mano de ? en TypeScript; si no, el tipo miente
uniqueRestricción de unicidad en una columnaPara varias columnas, @Unique({ properties: [...] }) en la entidad
defaultValor por defecto en el DDLNo rellena el objeto en memoria: tras el INSERT hay que recargarlo si lo necesitas
defaultRawExpresión SQL por defecto ('now()', 'gen_random_uuid()')Se evalúa en el servidor de base de datos
lengthLongitud o precisión temporal (varchar(n), timestamptz(n))En la v6, un Date sin length es timestamptz con precisión de microsegundos
precision / scaleDígitos totales y decimales de decimal/numericPara dinero: decimal más string o un tipo Dinero. Nunca number
fieldNameNombre real de la columnaSolo para bases heredadas; con naming strategy no hace falta
hiddenExcluye la propiedad de toObject() y toJSON()Útil para passwordHash, pero no es seguridad: sigue en memoria
persist: falseLa propiedad existe en el objeto pero no se mapea a ninguna columnaBase de las propiedades virtuales y de los campos calculados en memoria
lazy: trueNo se selecciona salvo que se pida explícitamentePara columnas grandes (contenido, blobs) que casi nunca hacen falta
onCreate / onUpdateFunción que calcula el valor al insertar o al actualizarLa forma idiomática de createdAt y updatedAt
version: trueColumna 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: trueIncluye esa propiedad en el WHERE de los UPDATEBloqueo optimista sin columna de versión, comprobando campos concretos

14.9.2 Tipos de clave primaria

claves-primarias.ts
// 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'
}
Por qué el UUID v7 es mejor que el v4 para los índices

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

indices.ts
@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

src/shared/base.entity.tsPATRÓN RECOMENDADO
// 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

src/domain/task.schema.ts
// 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 sitioEl dominio se publica como paquete compartido y no puede depender del ORM
Quieres que la definición y el tipo vivan juntosHay una regla arquitectónica que prohíbe importaciones de infraestructura en el dominio
El equipo ya conoce los decoradores de Nest y AngularTrabajas 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.

task.entity.tsINCORRECTO
@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;
}
task.entity.tsCORRECTO
@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().

wrap.ts
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);
El 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

hooks.ts
@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> { /* ... */ }
}
src/audit/audit.subscriber.ts
// 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 entidadEventSubscriber
Dónde se declaranMétodos decorados en la propia entidadClase aparte registrada en subscribers
AlcanceUna entidadVarias entidades, o todas
Eventos del vaciado (beforeFlush, onFlush, afterFlush)No disponiblesDisponibles
TestabilidadVan pegados al dominio; se ejecutan siempreSe prueban por separado y se pueden desactivar
Cuándo usarloNormalización trivial y coherencia interna de esa entidadAuditoría, eventos de dominio, outbox, multi-tenencia, cifrado de campos
task.entity.ts · hookINCORRECTO
@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);
  }
}
tasks.service.ts · en el caso de usoCORRECTO
// 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 });
  }
}
Qué NO hacer nunca dentro de un hook
  • 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á beforeFlush en 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

src/tasks/tasks.service.ts · las cuatro operaciones con el SQL que generan
@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().
  }
}
El hábito que distingue a un profesional Vuelve a leer los comentarios de SQL de este bloque. Todos salen de activar 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ónTipadoMigracionesRendimientoCurvaMadurez y comunidadIdeal para
MikroORM 6Data Mapper con Identity Map y Unit of Work realesExcelente: inferencia de relaciones cargadas (Loaded<T>) y de carga parcialPropias, con diff automático y snapshotMuy bueno en escritura (agrupación y transacción implícita); coste de CPU en el change trackingAlta: hay tres patrones que entenderMedia-alta. Mantenimiento muy activo y de un rigor notable, pero comunidad bastante menor que Prisma o TypeORMBackends con dominio rico, DDD, hexagonal, transacciones complejas. Encaje natural con NestJS
TypeORMLos dos modos: Active Record y Data Mapper (sin Unit of Work de verdad)Bueno, con huecos: any en varios puntos y relaciones poco estrechasPropias; synchronize muy peligroso en producciónAceptable; cada save() es un viajeBajaAlta adopción histórica; mantenimiento irregular durante añosProyectos existentes y equipos que ya lo dominan
PrismaNinguno de los clásicos: cliente generado con API de consulta propiaEl mejor del ecosistema: el cliente se genera desde el esquema, así que los resultados encajan al milímetroprisma migrate, muy pulido, con drift detectionMuy 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 TypeScriptLa más baja: prisma studio, autocompletado impecable y documentación ejemplarLa mayor con diferencia: comunidad, integraciones y material didácticoEquipos que quieren productividad inmediata, CRUD y API sobre un esquema estable
DrizzleQuery builder tipado; sin patrones de persistenciaExcelente y muy directo: el SQL que escribes es el que se ejecutadrizzle-kit, sencillo y explícitoSobresaliente: sin capa de hidratación ni seguimiento; ligero y apto para edge y serverlessBaja si sabes SQLJoven pero con crecimiento muy rápidoServicios pequeños, edge functions, equipos con buen SQL que quieren control total
SequelizeActive RecordAñadido después; el menos idiomático en TypeScriptPropias, basadas en umzugAceptable; el más antiguo y el que arrastra más decisiones heredadasBajaMuy alta por antigüedad (2011); enorme base instaladaMantenimiento de sistemas heredados en JavaScript
Knex / KyselyNo son ORM: construyen SQLKnex: flojo. Kysely: excelente, con tipos derivados del esquemaKnex incluye migraciones y seedsMáximo: es SQL con azúcarMuy baja si sabes SQLMuy 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

Criterio de decisión en una frase Si tu valor está en reglas de negocio y consistencia transaccional, elige un Data Mapper con Unit of Work (MikroORM). Si está en entregar rápido un CRUD sobre un esquema estable, elige Prisma. Si está en latencia, coste y control del SQL, elige Drizzle o Kysely. Y no reescribas un sistema que funciona solo por cambiar de ORM: es una de las migraciones con peor relación entre riesgo y beneficio que existen.

14.12 Errores comunes y cómo solucionarlos

Síntoma o errorCausa realSolución
Using global EntityManager instance methods for context specific actions is disallowedCó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 cambiaFalta await em.flush(), o el flush() está en una rama que no se ejecutaUn 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 entidadSe espera que persist() escriba. No escribe: solo apuntaawait em.flush() antes de leer la clave generada
Los cambios sobre una entidad no se guardan y no hay ningún errorLa entidad está separada: viene de otro contexto, de una caché, de un em.clear() o de disableIdentityMap: trueRecargarla 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 desarrolloImportació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ónMetadata provider incorrecto: TsMorphMetadataProvider sin fuentes .ts ni .d.ts en la imagen, o falta emitDecoratorMetadata con ReflectMetadataProviderUsar 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ónLa 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 errorColección no inicializada. Desde la v6 esto falla de forma explícita en lugar de devolver algo vacíopopulate de la colección, o await collection.init()
Memoria creciente y JavaScript heap out of memory en un script o workerEl Identity Map acumula todas las entidades cargadas más sus instantáneasProcesar 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 pendientesEs correcto. Si necesitas control estricto, FlushMode.COMMIT dentro de em.transactional()
Entidades desactualizadas en memoria tras una operación masivanativeUpdate/nativeDelete no pasan por el Unit of Work y no actualizan el Identity Mapem.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 recursoIdentity Map compartido: la información de «qué está poblado» viaja con la instanciaUn EM por petición (nunca allowGlobalContext en producción) y devolver DTOs, no entidades

14.13 Buenas y malas prácticas

Haz esto

  • Un EntityManager por unidad de trabajo y un solo flush() al final del caso de uso.
  • Mira el SQL generado de cada endpoint que escribas, con debug activo 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.
  • findOneOrFail en lugar de findOne más if: menos ruido y un error coherente.
  • getReference para asignar relaciones y para borrar: evita un SELECT inú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: true en la aplicación. Silencia un aviso y abre una fuga de datos entre usuarios.
  • persistAndFlush dentro de un bucle: N transacciones donde debería haber una.
  • Mutar entidades gestionadas «para calcular»: cualquier cambio es un UPDATE programado.
  • 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 --run en 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() o collection.loadCount().
  • number para decimal: los importes monetarios pierden precisión.
  • Exponer entidades con JSON.stringify confiando en hidden como 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
En Active Record, el objeto de dominio sabe guardarse a sí mismo: contiene datos, reglas de negocio y métodos de persistencia. En Data Mapper, el objeto de dominio no sabe que existe una base de datos, y un componente externo (el mapeador) traduce entre objetos y filas. La consecuencia práctica es la testabilidad: en Data Mapper puedes probar todas las reglas de negocio con new Task() y sin base de datos.
¿Qué problema resuelve el Identity Map y qué NO resuelve?
Resuelve la identidad: dentro de un contexto, una fila es siempre el mismo objeto en memoria, así que no puede haber dos versiones incoherentes de la misma tarea ni 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?
Porque es la mitad del patrón Unit of Work: 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()?
No. Al cargarla, la entidad quedó gestionada: está en el Identity Map y el Unit of Work guardó una instantánea de sus valores. En el 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?
Con una copia plana de los valores de cada entidad en el momento de la hidratación (la instantánea original, accesible con 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?
Porque lleva estado mutable: el Identity Map y la cola de cambios. Compartirlo produce tres problemas. Primero, memoria: no se puede limpiar sin romper otra petición en curso, así que crece indefinidamente. Segundo, fuga de datos: la información de «qué relaciones están cargadas» viaja con la instancia, de modo que un usuario puede recibir campos que otro pobló. Tercero, corrupción: un 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?
No exactamente. Un 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?
Casi nunca, y siempre dentro de 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?
Calcula los change sets, ordena las entidades con una ordenación topológica del grafo de dependencias de claves foráneas (los padres antes que los hijos), abre la transacción, ejecuta las inserciones, luego las actualizaciones y por último los borrados en orden inverso, y finalmente confirma y sincroniza el estado en memoria. Importa porque insertar un hijo antes que su padre viola la clave foránea: sin Unit of Work, ese orden lo tendrías que mantener a mano en cada caso de uso.
¿MikroORM me libra de aprender SQL?
No, y quien lo prometa te está engañando. Te libra de escribir el SQL repetitivo, pero para diagnosticar una consulta lenta, decidir un índice, entender un bloqueo o interpretar un plan de ejecución necesitas SQL. El ORM es una herramienta de productividad sobre la base de datos, no un sustituto de conocerla.
¿Qué cambia de la v5 a la v6 que me vaya a encontrar en tutoriales antiguos?
Lo principal: la opción 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?
No, porque su vida es la de la petición. No tiene expiración ni invalidación: nace vacío, se llena y muere. Precisamente por eso no sirve como caché entre peticiones, y por eso no se produce el problema clásico de datos rancios de una caché de aplicación. Lo que sí puede quedar desactualizado dentro de una misma petición es una entidad afectada por un nativeUpdate; ahí tienes em.refresh().

14.15 Ejercicios

Nivel 1 · básico

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é?

Nivel 2 · intermedio

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.

Nivel 3 · avanzado

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. RequestContext lo resuelve con AsyncLocalStorage; fuera de una petición, @CreateRequestContext() o fork().
  • El EntityRepository es 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

Siguiente paso Ya tienes el suelo firme: sabes qué ocurre y cuándo. El capítulo 15 usa estos tres patrones para lo que de verdad complica un modelo de datos: relaciones en sus cuatro cardinalidades, colecciones, referencias perezosas, cascadas, herencia y embeddables.