Parte IV · MikroORM

17. Transacciones, migraciones, filtros y rendimiento

Hasta aquí el ORM ha servido para leer y escribir filas. Este capítulo trata de lo que separa una aplicación de demostración de una aplicación que se puede desplegar el viernes por la tarde sin miedo: atomicidad (o todo o nada), concurrencia (dos usuarios tocando la misma fila al mismo tiempo), evolución del esquema (cambiar la base de datos sin cortar el servicio), reglas transversales (borrado lógico, multi-inquilino, auditoría) y rendimiento (importar medio millón de filas sin agotar la memoria ni el pool de conexiones). Son los temas en los que un error no produce un mensaje rojo en la consola: produce dinero descontado que nunca llegó a su destino, pedidos sin líneas o una migración que bloquea la tabla de usuarios durante once minutos.

MIKROORMAVANZADO Tiempo de lectura: ~130 min Prerrequisitos: capítulos 14 a 16 (Unit of Work, entidades, consultas) y nociones de SQL

17.1 Qué vas a poder hacer al terminar

Cómo leer este capítulo

Hay un hilo conductor detrás de las siete secciones grandes: toda escritura ocurre en un contexto. Ese contexto tiene un principio y un final (la transacción), un ámbito de visibilidad (el nivel de aislamiento), una identidad (el Identity Map), unas reglas transversales que se aplican sin que las escribas (los filtros y los eventos) y un coste en recursos compartidos (conexiones y memoria). Cuando algo se comporta de forma inexplicable en un ORM, la pregunta correcta casi siempre es: ¿en qué contexto se está ejecutando esto y quién lo abrió?

17.2 Transacciones: fundamentos

Una transacción es una secuencia de operaciones que la base de datos trata como una unidad indivisible: o se aplican todas o no se aplica ninguna. No es una característica del ORM, es una característica del motor de base de datos; el ORM únicamente decide dónde empieza y dónde acaba.

17.2.1 ACID con ejemplos concretos

PropiedadQué significaEjemplo real
Atomicidad Todas las operaciones se confirman juntas o se descartan juntas. No existe un estado «a medias» visible ni permanente. Un pedido con tres líneas: si la tercera línea falla por falta de stock, tampoco debe quedar la cabecera del pedido ni las dos primeras líneas.
Consistencia Al terminar, se cumplen todas las restricciones declaradas en la base de datos: claves foráneas, unicidad, CHECK, NOT NULL. Una línea de pedido nunca puede apuntar a un pedido inexistente porque la clave foránea se comprueba antes del COMMIT.
Islamiento Las transacciones concurrentes no ven los estados intermedios de las demás. El grado exacto depende del nivel de aislamiento. Mientras se está calculando una factura, otro usuario no ve las líneas ya insertadas y la cabecera todavía sin insertar.
Durabilidad Una vez confirmada, la transacción sobrevive a un corte de corriente: está escrita en el registro de transacciones en disco. Si el contenedor de la base de datos muere justo después del COMMIT, el pedido sigue ahí al reiniciar.
Qué NO garantiza una transacción
  • No garantiza que tu lógica sea correcta. Si calculas mal el total, lo calculas mal de forma atómica y duradera.
  • No garantiza aislamiento total salvo en SERIALIZABLE. En el nivel por defecto de PostgreSQL (READ COMMITTED) dos transacciones pueden leer el mismo saldo y pisarse la escritura: eso es la actualización perdida de la sección 17.5.
  • No abarca lo que está fuera de la base de datos. Si dentro de la transacción envías un correo, publicas en una cola o cobras con una pasarela de pago, un ROLLBACK posterior no deshace el correo, ni el mensaje, ni el cobro. Para eso existen los patrones outbox e idempotencia (capítulo 11).
  • No es gratis. Una transacción abierta retiene una conexión, mantiene bloqueos y, en PostgreSQL, impide que el recolector de versiones limpie filas antiguas.

17.2.2 Qué ocurre si no usas transacciones

El ejemplo canónico es la transferencia entre dos cuentas. Sin transacción, cada instrucción se confirma por separado (en autocommit) y basta con que el proceso muera entre las dos para que el dinero desaparezca.

transferencias.service.tsINCORRECTO
async transferir(origenId: string, destinoId: string, importe: number) {
  const origen = await this.em.findOneOrFail(Cuenta, origenId);
  origen.saldo -= importe;
  await this.em.flush();          // COMMIT nº 1: el dinero ya salió
  // Si el proceso muere aquí, o el destino no existe,
  // o la red se corta: el importe se ha evaporado.
  const destino = await this.em.findOneOrFail(Cuenta, destinoId);
  destino.saldo += importe;
  await this.em.flush();          // COMMIT nº 2 (puede no llegar nunca)
}
transferencias.service.tsCORRECTO
async transferir(origenId: string, destinoId: string, importe: number) {
  // Un único flush dentro de una única transacción:
  // ambos UPDATE viajan juntos o no viajan.
  await this.em.transactional(async (em) => {
    const origen  = await em.findOneOrFail(Cuenta, origenId);
    const destino = await em.findOneOrFail(Cuenta, destinoId);
    if (origen.saldo < importe) {
      throw new SaldoInsuficienteError(origenId);   // provoca ROLLBACK
    }
    origen.saldo  -= importe;
    destino.saldo += importe;
  });
}
Analogía: la caja del supermercado Una transacción es el ticket. Mientras el cajero pasa productos por el lector, nada es definitivo: puede anular una línea o cancelar la compra entera. Solo al cobrar (COMMIT) el ticket se emite y el inventario se ajusta. Sin ticket, cada producto sería una venta independiente: si te faltara dinero al final, habría que ir deshaciendo ventas una a una, y alguien podría haberse llevado ya la mercancía.

El segundo ejemplo, menos vistoso pero mucho más frecuente, es la creación de un agregado con hijos: un pedido con sus líneas. Sin transacción puedes acabar con cabeceras sin líneas (pedidos fantasma que aparecen en los informes con importe cero) o con líneas huérfanas si la clave foránea es NULL-able. El resultado no es un error visible, es un dato sucio que alguien descubrirá tres meses después cuadrando contabilidad.

SQL que genera un flush con transacción implícita
BEGIN;
INSERT INTO "pedido" ("cliente_id", "total", "creado_en")
  VALUES (7, 44.80, now()) RETURNING "id";                     -- id = 101
INSERT INTO "pedido_linea" ("pedido_id", "producto_id", "cantidad", "precio")
  VALUES (101, 3, 2, 19.90), (101, 8, 1, 5.00);                -- inserción por lotes
UPDATE "producto" SET "stock" = "stock" - 2 WHERE "id" = 3;
COMMIT;

17.3 Transacciones en MikroORM

17.3.1 La transacción implícita del flush()

MikroORM no escribe nada en la base de datos hasta que llamas a em.flush(). En ese momento el Unit of Work calcula los change sets (qué insertar, qué actualizar, qué borrar), los ordena por dependencias y los ejecuta dentro de una transacción propia si no hay ninguna activa. Es decir: un flush() ya es atómico por sí mismo.

  em.persist(a); em.persist(b); entidadC.nombre = 'x';   ← nada viaja a la BD todavía
                          │
                          ▼  em.flush()
      ┌─────────────────────────────────────────────────────┐
      │ 1. beforeFlush                                      │  (puedes aún modificar entidades)
      │ 2. computeChangeSets()  ← diff contra la instantánea │
      │ 3. onFlush              ← change sets ya calculados  │
      ├─────────────────────────────────────────────────────┤
      │ 4. BEGIN            (solo si no había transacción)   │
      │ 5. INSERT / UPDATE / DELETE ordenados por topología  │
      │ 6. COMMIT  ó  ROLLBACK si algo lanza                 │
      ├─────────────────────────────────────────────────────┤
      │ 7. afterFlush                                       │
      └─────────────────────────────────────────────────────┘
                          ▼
      Identity Map intacto · instantáneas actualizadas al nuevo estado

La consecuencia práctica es importante: lo que determina la unidad atómica no son tus llamadas a persist, es dónde pones el flush. Dos flush() seguidos son dos transacciones separadas, con todo lo que eso implica.

Dos flush() no son una transacción Es el error de la sección 17.2.2 disfrazado. Si un caso de uso necesita atomicidad, o acumula todos los cambios y hace un único flush(), o los envuelve explícitamente en em.transactional(). No hay tercera opción.

17.3.2 em.transactional(): el EM forkeado

em.transactional(callback) abre una transacción, crea un fork del EntityManager (un contexto nuevo con su propio Identity Map y su propio Unit of Work) y lo pasa como argumento al callback. Al terminar hace flush() y COMMIT; si el callback lanza, hace ROLLBACK y propaga la excepción. Devuelve lo que devuelva el callback.

pedidos.service.ts
import { EntityManager } from '@mikro-orm/postgresql';
async crearPedido(dto: CrearPedidoDto): Promise<string> {
  // El valor devuelto por el callback es el valor devuelto por transactional.
  const id = await this.em.transactional(async (em) => {
    const cliente = await em.findOneOrFail(Cliente, dto.clienteId);
    const pedido  = em.create(Pedido, { cliente, estado: 'PENDIENTE' });
    for (const linea of dto.lineas) {
      const producto = await em.findOneOrFail(Producto, linea.productoId);
      // Cualquier excepción provoca el ROLLBACK automático de todo el bloque.
      if (producto.stock < linea.cantidad) throw new StockInsuficienteError(producto.sku);
      producto.stock -= linea.cantidad;
      em.create(PedidoLinea, { pedido, producto, cantidad: linea.cantidad, precio: producto.precio });
    }
    // No hace falta llamar a em.flush(): transactional lo hace al final.
    return pedido.id;
  });
  return id;
}

El error clásico es ignorar el parámetro y seguir usando el em del servicio. Las operaciones lanzadas contra el EM externo no pertenecen a la transacción: viven en otro contexto, con otro Unit of Work y, dependiendo del driver y del pool, con otra conexión. El resultado es un rollback que no deshace la mitad del trabajo o, peor, un bloqueo mutuo entre dos conexiones del mismo proceso.

facturas.service.tsINCORRECTO
async emitir(id: string) {
  await this.em.transactional(async (em) => {
    const f = await em.findOneOrFail(Factura, id);
    f.estado = 'EMITIDA';
    // this.em NO es el em de la transacción:
    // este flush se confirma por separado y
    // sobrevive al ROLLBACK de la transacción.
    const contador = await this.em.findOneOrFail(Contador, 'facturas');
    contador.siguiente++;
    await this.em.flush();
    throw new Error('algo falla después');
  });
  // Resultado: la factura NO se emite,
  // pero el contador YA se ha incrementado. Hueco en la numeración.
}
facturas.service.tsCORRECTO
async emitir(id: string) {
  await this.em.transactional(async (em) => {
    const f = await em.findOneOrFail(Factura, id);
    f.estado = 'EMITIDA';
    // Todo dentro del MISMO em forkeado:
    // un único COMMIT o un único ROLLBACK.
    const contador = await em.findOneOrFail(Contador, 'facturas');
    contador.siguiente++;
    throw new Error('algo falla después');
  });
  // Resultado: nada cambia. Ni factura ni contador.
}
Cómo blindarse contra este error

Sombrear el nombre (async (em) => ... dentro de una clase cuyo campo es this.em) ayuda, pero no impide escribir this.em por costumbre. Tres medidas eficaces:

  • Extraer el cuerpo de la transacción a un método privado que reciba el em como parámetro explícito: dentro de ese método no existe this.em que confundir.
  • En repositorios y servicios de dominio, aceptar siempre un em?: EntityManager opcional y usar em ?? this.em.
  • Activar en desarrollo el registro de SQL con el contexto de transacción y revisar que no aparezcan dos BEGIN donde esperabas uno.

17.3.3 Transacciones anidadas y savepoints

Si llamas a em.transactional() con un EntityManager que ya está dentro de una transacción, MikroORM no abre una segunda transacción real (la mayoría de motores SQL no lo permiten): crea un savepoint. Un fallo en el bloque interno deshace solo hasta el savepoint; la transacción externa sigue viva y puede confirmar el resto.

SQL de una transacción anidada
BEGIN;
  UPDATE "pedido" SET "estado" = 'PAGADO' WHERE "id" = 101;
  SAVEPOINT "trx1";                       -- entra el bloque interno
    INSERT INTO "notificacion" ("pedido_id", "canal") VALUES (101, 'EMAIL');
  ROLLBACK TO SAVEPOINT "trx1";           -- el bloque interno falló
  -- la notificación se descarta, el pedido sigue pagado
COMMIT;

Esto habilita un patrón útil: operaciones opcionales dentro de un caso de uso obligatorio. El pago debe confirmarse; el registro de la notificación es deseable pero no crítico, así que se envuelve en su propio bloque con su propio try/catch. Úsalo con moderación: si abusas de los savepoints acabas con transacciones largas y con una semántica difícil de razonar en las revisiones de código.

Opciones de transactional

El segundo argumento acepta, entre otras, isolationLevel (sección 17.4), readOnly, ctx (para reutilizar un contexto de transacción existente) y clear (si el fork arranca con el Identity Map vacío). Existe además una opción para ignorar el anidamiento y unirse a la transacción externa en lugar de crear un savepoint. Los nombres exactos y el conjunto disponible cambian entre versiones menores: consulta la página de transacciones de la documentación de tu versión de MikroORM 6 antes de fijarlos en un proyecto.

17.3.4 Control manual: begin, commit, rollback

importador.ts · control manual
const em = this.em.fork();          // contexto propio: nunca uses el EM global
await em.begin();
try {
  await em.persistAndFlush(cabecera);
  await this.procesarLotes(em);
  await em.commit();                // commit hace flush implícito de lo pendiente
} catch (e) {
  await em.rollback();              // obligatorio: si no, la conexión queda retenida
  throw e;
}

Cuándo hace falta. Muy pocas veces. El control manual solo se justifica cuando el principio y el final de la transacción no caben en un mismo ámbito léxico: una máquina de estados dirigida por eventos, un adaptador que integra una librería de terceros que exige la conexión abierta, o una transacción por petición implementada en un middleware. En cualquier otro caso, em.transactional() es superior porque el rollback no se puede olvidar.

El fallo mortal del control manual Un begin() sin su commit() ni su rollback() en todas las rutas de salida (incluidos los return tempranos) deja una transacción abierta y una conexión fuera del pool. Con suficiente tráfico, el pool se agota y la aplicación deja de responder con un timeout acquiring a connection que no menciona en ningún momento la causa real.

17.3.5 @Transactional() y @CreateRequestContext()

Son dos decoradores que resuelven dos problemas distintos y que se confunden constantemente.

@CreateRequestContext()@Transactional()
Qué crea Un contexto de EntityManager (un fork con su Identity Map) para la duración del método. Un contexto y además una transacción de base de datos alrededor del método.
Problema que resuelve «Using global EntityManager instance methods for context specific actions is disallowed»: código que corre fuera de una petición HTTP y por tanto sin contexto. Atomicidad declarativa de un caso de uso, sin anidar un callback.
Dónde se usa Tareas programadas (cron), consumidores de colas, escuchadores de eventos, comandos de CLI, scripts de mantenimiento, procesos de arranque. Métodos de servicios de aplicación que ya tienen contexto (dentro de una petición HTTP) y necesitan atomicidad.
Confirma al terminar No. Tú decides cuándo llamar a flush(). Sí: flush() y COMMIT, o ROLLBACK si lanza.
informes.cron.ts · contexto fuera de una petición
import { CreateRequestContext, MikroORM } from '@mikro-orm/core';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class InformesCron {
  // El decorador NECESITA encontrar la instancia de MikroORM (o un EntityManager)
  // en una propiedad de la clase; por eso se inyecta aunque no se use directamente.
  constructor(private readonly orm: MikroORM, private readonly em: EntityManager) {}
  @Cron(CronExpression.EVERY_DAY_AT_3AM)
  @CreateRequestContext()
  async generarInformeDiario(): Promise<void> {
    // Aquí this.em resuelve al fork creado por el decorador:
    // Identity Map propio, aislado del resto de la aplicación.
    const ventas = await this.em.find(Venta, { fecha: { $gte: ayer() } });
    this.em.create(InformeDiario, { fecha: ayer(), total: sumar(ventas) });
    await this.em.flush();
  }
}
Detalles que cuestan una tarde
  • En MikroORM 5 el decorador se llamaba @UseRequestContext(). En la versión 6 el nombre es @CreateRequestContext(), y existe además una variante que reutiliza el contexto si ya hay uno activo en lugar de crear otro (@EnsureRequestContext()). Usa la primera para código que siempre corre fuera de una petición y la segunda para métodos que pueden invocarse desde ambos sitios.
  • El decorador crea el contexto al invocar el método. Una referencia al em capturada antes (por ejemplo en el constructor y guardada en otra variable) no se beneficia de él.
  • Los decoradores no funcionan sobre funciones sueltas ni sobre arrow functions asignadas a propiedades. En un script suelto usa RequestContext.create(orm.em, async () => { ... }) o orm.em.fork() explícitamente.
cobros.service.ts · atomicidad declarativa
import { Transactional } from '@mikro-orm/core';
@Injectable()
export class CobrosService {
  constructor(private readonly em: EntityManager) {}
  // Equivale a envolver todo el cuerpo en em.transactional(...).
  // El em inyectado resuelve al contexto transaccional durante la ejecución.
  @Transactional()
  async aplicarCobro(pedidoId: string, importe: number): Promise<void> {
    const pedido = await this.em.findOneOrFail(Pedido, pedidoId);
    pedido.pagado += importe;
    if (pedido.pagado >= pedido.total) pedido.estado = 'PAGADO';
    this.em.create(MovimientoCaja, { pedido, importe });
  }
}
Sobre la firma exacta de @Transactional() El decorador de transacción existe en MikroORM 6 y acepta opciones análogas a las de em.transactional() (nivel de aislamiento, contexto, solo lectura). Como su superficie ha crecido entre versiones menores, comprueba en la documentación de tu versión qué opciones admite y de qué propiedad de la clase obtiene el EntityManager antes de apoyarte en él en producción. Si dudas, em.transactional() explícito es siempre equivalente y no depende de metadatos de decoradores.

17.3.6 Transacción por petición HTTP

La idea es tentadora: abrir una transacción al principio de cada petición que modifique datos y confirmarla si el controlador responde sin error. Se implementa con un interceptor de Nest que envuelve la ejecución del manejador dentro de em.transactional() y crea el contexto de petición con el EM transaccional, para que los servicios que inyectan EntityManager reciban ese mismo fork.

src/database/transaction.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { EntityManager, RequestContext } from '@mikro-orm/core';
import { Observable, from, lastValueFrom } from 'rxjs';
const METODOS_SEGUROS = new Set(['GET', 'HEAD', 'OPTIONS']);
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly em: EntityManager) {}
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest<{ method: string }>();
    // Las lecturas no necesitan transacción explícita: evita bloqueos innecesarios.
    if (METODOS_SEGUROS.has(req.method)) return next.handle();
    return from(
      // Sin RequestContext.create, los servicios seguirían resolviendo al EM de
      // la petición en lugar de al forkeado de la transacción.
      this.em.transactional((txEm) =>
        RequestContext.create(txEm, () => lastValueFrom(next.handle())),
      ),
    );
  }
}

Ventajas

  • Atomicidad del caso de uso completo sin que cada servicio tenga que acordarse de ella.
  • Un error no controlado en la capa que sea (validación tardía, serialización, un guard posterior) deja la base de datos limpia.
  • Elimina la clase entera de errores «se guardó la mitad».

Riesgos

  • Transacciones largas. La transacción dura tanto como la petición, incluido el tiempo de serialización y cualquier espera.
  • Llamadas externas dentro de la transacción. Un servicio que llama a una API de terceros con 3 s de latencia mantiene bloqueos durante 3 s.
  • Bloqueos y agotamiento del pool bajo carga: cada petición en vuelo retiene una conexión.
  • Falsa sensación de atomicidad: los efectos externos (correos, mensajes en cola) no se deshacen.
  • Peticiones que solo leen y que por error entran en la rama transaccional pagan el coste sin beneficio.
Recomendación razonada

Transacción por caso de uso, no por petición. Coloca la frontera transaccional en el servicio de aplicación (el método que representa la intención del usuario: crear pedido, transferir, cerrar caja), con em.transactional() o @Transactional(). Es explícita, visible en la revisión de código, y deja fuera todo lo que no debe estar dentro: validaciones previas, llamadas HTTP a terceros, generación de PDF, envío de notificaciones.

El interceptor global es defendible en dos escenarios: una aplicación CRUD homogénea donde casi todos los endpoints de escritura son un único caso de uso corto, o como red de seguridad temporal en una base de código heredada mientras se introducen fronteras explícitas. Si lo adoptas, exige tres cosas: excluir los métodos seguros, prohibir por convención (y por revisión) las llamadas de red dentro de servicios transaccionales, y vigilar la métrica de duración de transacción.

17.4 Niveles de aislamiento

El aislamiento perfecto (que todo ocurra como si las transacciones se ejecutaran una detrás de otra) es caro. El estándar SQL define cuatro niveles que intercambian corrección por concurrencia, y cada nivel se define por las anomalías que permite.

17.4.1 Las cuatro anomalías, con línea temporal

Lectura sucia (dirty read): leer un dato que otra transacción ha escrito y todavía no ha confirmado.

  T1 (transfiere)                     T2 (informe de saldos)
  ──────────────────────────────────────────────────────────────────
  BEGIN
  UPDATE cuenta SET saldo = 0
    WHERE id = 'A'          (saldo real: 1000)
                                      BEGIN
                                      SELECT saldo FROM cuenta WHERE id='A'
                                        → 0        ¡dato NO confirmado!
                                      -- decide bloquear la cuenta por saldo cero
  ROLLBACK   (el saldo vuelve a 1000)
                                      COMMIT
  ──────────────────────────────────────────────────────────────────
  Resultado: T2 tomó una decisión sobre un dato que nunca existió.

Lectura no repetible (non-repeatable read): la misma fila leída dos veces en la misma transacción devuelve valores distintos, porque otra transacción la modificó y confirmó en medio.

  T1 (valida y cobra)                 T2 (el cliente cambia el descuento)
  ──────────────────────────────────────────────────────────────────
  BEGIN
  SELECT descuento FROM cliente       → 10
                                      BEGIN
                                      UPDATE cliente SET descuento = 50
                                      COMMIT
  SELECT descuento FROM cliente       → 50      ¡cambió dentro de T1!
  -- validó con 10 y cobra con 50
  COMMIT

Lectura fantasma (phantom read): la misma consulta con la misma condición devuelve un conjunto de filas distinto porque otra transacción insertó o borró filas que cumplen el criterio.

  T1 (cierre de caja)                 T2 (nueva venta)
  ──────────────────────────────────────────────────────────────────
  BEGIN
  SELECT count(*) FROM venta
    WHERE dia = hoy()                 → 120
                                      BEGIN
                                      INSERT INTO venta (dia,...) VALUES (hoy(),...)
                                      COMMIT
  SELECT sum(importe) FROM venta
    WHERE dia = hoy()                 → suma de 121 filas    ¡fantasma!
  COMMIT
  ──────────────────────────────────────────────────────────────────
  Resultado: el informe dice 120 ventas y un importe que corresponde a 121.

Anomalía de serialización (incluye el write skew): cada transacción es correcta por separado y ninguna lee datos sucios, pero el resultado conjunto viola una invariante que ninguna de las dos pudo ver.

  Invariante: siempre debe quedar al menos un médico de guardia.
  Estado inicial: guardia = { Ana, Luis }
  T1 (Ana pide el turno libre)        T2 (Luis pide el turno libre)
  ──────────────────────────────────────────────────────────────────
  BEGIN                               BEGIN
  SELECT count(*) FROM guardia → 2    SELECT count(*) FROM guardia → 2
  -- 2 > 1, puedo salir               -- 2 > 1, puedo salir
  DELETE FROM guardia WHERE m='Ana'   DELETE FROM guardia WHERE m='Luis'
  COMMIT                              COMMIT
  ──────────────────────────────────────────────────────────────────
  Estado final: guardia = { }   La invariante se rompió sin ninguna lectura sucia.
  Solo SERIALIZABLE (o un bloqueo explícito) evita esto.

17.4.2 Qué evita cada nivel

NivelLectura suciaLectura no repetibleFantasma Anomalía de serializaciónCoste
READ UNCOMMITTED PermitidaPermitidaPermitidaPermitida Mínimo. Casi nunca justificable.
READ COMMITTED EvitadaPermitidaPermitidaPermitida Bajo. Es el punto de equilibrio habitual.
REPEATABLE READ EvitadaEvitadaDepende del motorPermitida Medio: más versiones retenidas, posibles errores de serialización.
SERIALIZABLE EvitadaEvitadaEvitadaEvitada Alto: bloqueos o abortos frecuentes; obliga a reintentar.
Los niveles no significan lo mismo en cada motor
  • PostgreSQL. Por defecto READ COMMITTED. No implementa realmente READ UNCOMMITTED: lo trata como READ COMMITTED. Su REPEATABLE READ es aislamiento por instantánea, así que tampoco permite fantasmas, pero puede abortar la transacción con el error 40001 («could not serialize access due to concurrent update»). Su SERIALIZABLE usa comprobación de serialización de instantáneas: no bloquea de más, pero aborta con 40001 y hay que reintentar.
  • MySQL/MariaDB con InnoDB. Por defecto REPEATABLE READ, implementado con lecturas consistentes por instantánea más gap locks en las lecturas con bloqueo. Cuidado: una lectura con FOR UPDATE dentro de REPEATABLE READ ve la última versión confirmada, no la instantánea, lo que sorprende a quien viene de PostgreSQL.
  • SQLite. Serializa las escrituras a nivel de fichero o de base de datos; el concepto de nivel de aislamiento apenas aplica.

Conclusión práctica: no escribas código que dependa de sutilezas del nivel de aislamiento. Si una invariante importa, protégela con una restricción de la base de datos o con un bloqueo explícito.

17.4.3 Cómo fijar el nivel en MikroORM

reservas.service.ts
import { IsolationLevel } from '@mikro-orm/core';
// Por transacción: lo habitual y lo recomendable.
await this.em.transactional(
  async (em) => {
    const aforo = await em.count(Reserva, { sesion: sesionId });
    if (aforo >= capacidad) throw new AforoCompletoError();
    em.create(Reserva, { sesion: sesionId, usuario: usuarioId });
  },
  { isolationLevel: IsolationLevel.SERIALIZABLE },
);
// Con control manual, el nivel se pasa al begin.
await em.begin({ isolationLevel: IsolationLevel.REPEATABLE_READ });
SQL resultante (PostgreSQL)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS "count" FROM "reserva" WHERE "sesion_id" = 42;
INSERT INTO "reserva" ("sesion_id", "usuario_id") VALUES (42, 9);
COMMIT;
-- Si otra transacción concurrente hizo lo mismo:
-- ERROR: could not serialize access due to read/write dependencies among transactions (40001)
-- → la aplicación DEBE reintentar la operación completa.
Subir el nivel obliga a reintentar Con SERIALIZABLE (y con REPEATABLE READ en PostgreSQL) la base de datos puede abortar una transacción perfectamente correcta porque otra concurrente le ganó. Si no envuelves la operación en un reintento, has cambiado un error de datos silencioso por un error 500 aleatorio bajo carga. El nivel alto solo es una solución completa acompañado de reintentos idempotentes.

Cuándo subir el nivel: invariantes que abarcan varias filas y que no puedes expresar con una restricción (aforo, saldo mínimo agregado, «al menos un administrador», asignación de turnos), informes que deben ver una instantánea coherente de varias tablas, y procesos de cierre contable. Cuándo no: como solución genérica al problema de la actualización perdida sobre una sola fila; ahí el bloqueo optimista o pesimista es más barato y más preciso.

17.5 Concurrencia: bloqueo optimista y pesimista

17.5.1 El problema de la actualización perdida

Es el problema de concurrencia más común y el que más dinero cuesta. Ocurre siempre que el código sigue el patrón leer → decidir en memoria → escribir, porque entre la lectura y la escritura hay una ventana en la que otro proceso puede hacer lo mismo.

  Stock inicial del producto 3: 10 unidades.
  Dos peticiones simultáneas compran 8 unidades cada una.
  Petición A (nodo 1)                       Petición B (nodo 2)
  ─────────────────────────────────────────────────────────────────────────
  BEGIN                                     BEGIN
  SELECT stock FROM producto WHERE id=3
     → 10
                                            SELECT stock FROM producto WHERE id=3
                                               → 10          (¡lee lo mismo!)
  -- 10 >= 8, venta permitida
                                            -- 10 >= 8, venta permitida
  UPDATE producto SET stock = 2 WHERE id = 3  (B espera el bloqueo de fila)
  COMMIT
                                            UPDATE producto SET stock = 2 WHERE id = 3
                                            COMMIT
  ─────────────────────────────────────────────────────────────────────────
  Stock final: 2.   Vendidas: 16 unidades de 10 existentes.
  La escritura de A se ha PERDIDO: B la sobrescribió con un valor calculado
  a partir de un dato ya obsoleto.

Hay tres formas de cerrar esa ventana, en orden creciente de coste:

  1. Escritura atómica relativa. Que la base de datos haga la aritmética: UPDATE producto SET stock = stock - 8 WHERE id = 3 AND stock >= 8. Si afecta a cero filas, no había stock. Es la opción más eficiente y la que más se olvida.
  2. Bloqueo optimista. Permitir la carrera y detectarla al escribir mediante una columna de versión.
  3. Bloqueo pesimista. Impedir la carrera bloqueando la fila en el momento de leerla.
stock.service.tsINCORRECTO
// Leer, decidir en memoria y escribir:
// dos peticiones concurrentes venden el mismo stock.
async reservar(id: string, cantidad: number) {
  const p = await this.em.findOneOrFail(Producto, id);
  if (p.stock < cantidad) throw new StockInsuficienteError(id);
  p.stock -= cantidad;      // valor calculado con un dato posiblemente obsoleto
  await this.em.flush();
}
stock.service.tsCORRECTO
// La condición y la resta las evalúa la base de datos
// en la misma instrucción: no hay ventana de carrera.
async reservar(id: string, cantidad: number) {
  const filas = await this.em.nativeUpdate(
    Producto,
    { id, stock: { $gte: cantidad } },
    { stock: this.em.raw(`stock - ${'?'}`, [cantidad]) },
  );
  if (filas === 0) throw new StockInsuficienteError(id);
}
Precio de la escritura atómica nativeUpdate pasa por encima del Unit of Work: no dispara hooks de entidad, no actualiza las entidades que ya estuvieran cargadas en el Identity Map y no incrementa la columna de versión. Si en la misma petición vuelves a leer el producto desde el mismo contexto, obtendrás el valor antiguo salvo que hagas em.refresh(p) o em.clear(). Úsalo para contadores y decrementos, no como estilo general de escritura. Comprueba en la documentación de tu versión la forma exacta de expresar una expresión SQL cruda en el objeto de datos (raw() como función importada o como método del EM).

17.5.2 Bloqueo optimista

La estrategia optimista asume que los conflictos son raros: no bloquea nada, pero añade a la entidad una columna de versión que se incrementa en cada actualización. La cláusula WHERE del UPDATE incluye la versión leída; si otra transacción ya la cambió, el UPDATE afecta a cero filas y el ORM lanza una excepción.

src/entities/producto.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
export class Producto {
  @PrimaryKey() id!: string;
  @Property() nombre!: string;
  @Property() stock!: number;
  // Versión numérica: la opción recomendada. Empieza en 1 y la gestiona el ORM.
  @Property({ version: true }) version!: number;
}
@Entity()
export class Documento {
  @PrimaryKey() id!: string;
  @Property({ type: 'text' }) contenido!: string;
  // Versión por marca de tiempo: legible en informes, pero con menos resolución;
  // dos actualizaciones en el mismo milisegundo pueden no detectarse como conflicto.
  @Property({ version: true, length: 6 }) actualizadoEn!: Date;
}
SQL generado por el bloqueo optimista
-- Lectura: el ORM guarda version = 7 en la instantánea del Unit of Work
SELECT "id", "nombre", "stock", "version" FROM "producto" WHERE "id" = 'p-3';
-- Escritura: la versión leída viaja en el WHERE y se incrementa en el SET
UPDATE "producto"
   SET "stock" = 2, "version" = 8
 WHERE "id" = 'p-3' AND "version" = 7;
-- Si otra transacción confirmó antes, la fila ya tiene version = 8:
-- filas afectadas = 0  →  MikroORM lanza OptimisticLockError

El mecanismo cubre además un caso que el bloqueo pesimista no puede cubrir: la edición concurrente entre peticiones distintas. Si un formulario web se carga con version = 7, el usuario tarda cinco minutos en enviarlo y otro usuario ha guardado en medio, la versión enviada por el cliente permite detectar el conflicto aunque no exista ninguna transacción abierta durante esos cinco minutos.

productos.controller.ts · verificación con la versión del cliente
import { LockMode, OptimisticLockError } from '@mikro-orm/core';
import { ConflictException } from '@nestjs/common';
@Patch(':id')
// dto.version es la versión que el cliente leyó al cargar el formulario.
async actualizar(@Param('id') id: string, @Body() dto: ActualizarProductoDto): Promise<ProductoDto> {
  try {
    return await this.em.transactional(async (em) => {
      // Comprueba la versión ANTES de aplicar cambios: falla rápido.
      const p = await em.findOneOrFail(Producto, id, {
        lockMode: LockMode.OPTIMISTIC, lockVersion: dto.version,
      });
      em.assign(p, { nombre: dto.nombre, precio: dto.precio });
      return aDto(p);
    });
  } catch (e) {
    if (e instanceof OptimisticLockError) {
      // 409: el recurso cambió desde que el cliente lo leyó.
      throw new ConflictException({ code: 'VERSION_CONFLICT',
        message: 'El producto ha sido modificado por otro usuario. Recarga y vuelve a intentarlo.' });
    }
    throw e;
  }
}
Cómo manejar el 409 en el cliente y en el servidor
  • Conflictos de edición humana (dos personas editando la misma ficha): responde 409 con el estado actual del recurso y deja que la interfaz muestre las diferencias. Reintentar en el servidor sería perder el cambio de alguien en silencio, que es exactamente lo que queríamos evitar.
  • Conflictos entre procesos automáticos (dos consumidores de una cola incrementando un contador): reintenta en el servidor con retroceso exponencial y jitter, releyendo la entidad en cada intento. Tres intentos suelen bastar; si no bastan, el diseño necesita una escritura atómica o una cola por clave.
  • En Angular, un interceptor puede traducir el 409 con code: 'VERSION_CONFLICT' en un diálogo de «recargar o sobrescribir» reutilizable.

Cuando no puedes o no quieres añadir una columna de versión (por ejemplo en una tabla heredada), MikroORM ofrece concurrencyCheck a nivel de propiedad: las propiedades marcadas se incluyen en el WHERE del UPDATE con el valor que tenían al cargar la entidad. Es un bloqueo optimista basado en el contenido en lugar de en un contador.

src/entities/tarifa.entity.ts
@Entity()
export class Tarifa {
  @PrimaryKey()
  id!: string;
  // Sin columna de versión: el propio importe actúa de testigo.
  @Property({ concurrencyCheck: true })
  importe!: number;
  @Property()
  descripcion!: string;
}
// UPDATE "tarifa" SET "importe" = 120 WHERE "id" = 't1' AND "importe" = 100;
// 0 filas afectadas → OptimisticLockError

17.5.3 Bloqueo pesimista

La estrategia pesimista asume que el conflicto es probable y lo evita bloqueando la fila en la base de datos en el momento de leerla. Solo tiene sentido dentro de una transacción: el bloqueo se libera con el COMMIT o el ROLLBACK.

LockModeSQL (PostgreSQL)Semántica
PESSIMISTIC_READ SELECT ... FOR SHARE Otros pueden leer, nadie puede modificar la fila hasta que termines.
PESSIMISTIC_WRITE SELECT ... FOR UPDATE Bloqueo exclusivo. Los demás FOR UPDATE sobre esa fila esperan.
PESSIMISTIC_PARTIAL_WRITE SELECT ... FOR UPDATE SKIP LOCKED No espera: omite las filas ya bloqueadas por otros. La base de una cola.
PESSIMISTIC_WRITE_OR_FAIL SELECT ... FOR UPDATE NOWAIT No espera: falla inmediatamente si la fila está bloqueada.
transferencias.service.ts · bloqueo pesimista con orden estable
import { LockMode } from '@mikro-orm/core';
async transferir(origenId: string, destinoId: string, importe: number): Promise<void> {
  if (origenId === destinoId) throw new BadRequestException('Cuentas iguales');
  await this.em.transactional(async (em) => {
    // CLAVE: bloquear SIEMPRE en el mismo orden (por id) en todo el sistema.
    // Si un caso de uso bloquea A→B y otro B→A, aparecen deadlocks.
    const [primero, segundo] = [origenId, destinoId].sort();
    const c1 = await em.findOneOrFail(Cuenta, primero,  { lockMode: LockMode.PESSIMISTIC_WRITE });
    const c2 = await em.findOneOrFail(Cuenta, segundo, { lockMode: LockMode.PESSIMISTIC_WRITE });
    const [origen, destino] = c1.id === origenId ? [c1, c2] : [c2, c1];
    if (origen.saldo < importe) throw new SaldoInsuficienteError(origenId);
    origen.saldo  -= importe;
    destino.saldo += importe;
    em.create(Movimiento, { origen, destino, importe });
  });
}
// Si la entidad ya está cargada: await em.lock(cuenta, LockMode.PESSIMISTIC_WRITE);
SQL del bloqueo pesimista
BEGIN;
SELECT "id", "saldo" FROM "cuenta" WHERE "id" = 'A' FOR UPDATE;   -- bloquea la fila A
SELECT "id", "saldo" FROM "cuenta" WHERE "id" = 'B' FOR UPDATE;   -- bloquea la fila B
UPDATE "cuenta" SET "saldo" = 900 WHERE "id" = 'A';
UPDATE "cuenta" SET "saldo" = 1100 WHERE "id" = 'B';
INSERT INTO "movimiento" ("origen_id", "destino_id", "importe") VALUES ('A', 'B', 100);
COMMIT;   -- aquí se liberan los dos bloqueos

17.5.4 Una cola de trabajos en la base de datos con SKIP LOCKED

SKIP LOCKED resuelve el problema de reparto entre trabajadores sin coordinación externa: cada trabajador toma las primeras filas pendientes que no estén bloqueadas por otro. Es la forma correcta de implementar una cola sencilla cuando no quieres añadir Redis a la arquitectura.

  tabla trabajo:  id | estado    | intentos | payload
                  1..4 | PENDIENTE |   0    | ...
   Trabajador 1                     Trabajador 2
   ────────────────────────────────────────────────────────────────
   BEGIN                            BEGIN
   SELECT ... LIMIT 2
     FOR UPDATE SKIP LOCKED
     → filas 1, 2  (bloqueadas)
                                    SELECT ... LIMIT 2
                                      FOR UPDATE SKIP LOCKED
                                      → filas 3, 4  (omite 1 y 2)
   UPDATE estado='EN_CURSO' (1,2)   UPDATE estado='EN_CURSO' (3,4)
   COMMIT                           COMMIT
   ────────────────────────────────────────────────────────────────
   Ningún trabajo se procesa dos veces y ningún trabajador espera al otro.
src/jobs/job.repository.ts
import { LockMode } from '@mikro-orm/core';
async reclamarLote(tamano = 10): Promise<Trabajo[]> {
  return this.em.transactional(async (em) => {
    const trabajos = await em.find(
      Trabajo,
      { estado: 'PENDIENTE', disponibleEn: { $lte: new Date() } },
      { limit: tamano, orderBy: { prioridad: 'desc', creadoEn: 'asc' },
        lockMode: LockMode.PESSIMISTIC_PARTIAL_WRITE },   // FOR UPDATE SKIP LOCKED
    );
    for (const t of trabajos) {
      t.estado = 'EN_CURSO';
      t.intentos++;
      t.reclamadoEn = new Date();
    }
    return trabajos;   // flush y COMMIT los hace transactional
  });
}
SQL de la reclamación de lote
BEGIN;
SELECT "t0".* FROM "trabajo" AS "t0"
 WHERE "t0"."estado" = 'PENDIENTE' AND "t0"."disponible_en" <= now()
 ORDER BY "t0"."prioridad" DESC, "t0"."creado_en" ASC
 LIMIT 10
   FOR UPDATE SKIP LOCKED;
UPDATE "trabajo" SET "estado" = 'EN_CURSO', "intentos" = 1, "reclamado_en" = now()
 WHERE "id" IN (1, 2, 3);
COMMIT;
Límites de una cola en la base de datos Funciona muy bien hasta unos pocos miles de trabajos por minuto y tiene una ventaja enorme: el trabajo se encola en la misma transacción que el cambio de negocio, así que nunca hay mensajes de eventos que no ocurrieron (es el patrón outbox). Sus límites son la carga de escritura sobre la tabla (mucho UPDATE genera muchas versiones muertas en PostgreSQL: vigila el autovacuum) y la falta de funciones avanzadas (prioridades dinámicas, reintentos programados sofisticados, paneles). Cuando lleguen esos límites, BullMQ (capítulo 11) es el paso siguiente natural.

17.5.5 Optimista frente a pesimista: criterios de elección

CriterioBloqueo optimistaBloqueo pesimista
Cómo funciona Detecta el conflicto al escribir (columna de versión en el WHERE). Lo previene al leer (SELECT ... FOR UPDATE).
Coste en la base de datos Casi nulo: ningún bloqueo adicional. Bloqueos de fila mientras dure la transacción; otros esperan.
Requiere transacción abierta No para detectar; sí para escribir de forma atómica. Sí, obligatoriamente.
Funciona entre peticiones HTTP : la versión viaja al cliente y vuelve. No: nunca mantengas una transacción abierta esperando a un usuario.
Comportamiento con mucha contención Malo: muchos conflictos, muchos reintentos, trabajo desperdiciado. Bueno: se serializa el acceso, cada uno espera su turno.
Riesgo de deadlock Muy bajo. Real si el orden de bloqueo no es coherente.
Experiencia de usuario Puede recibir un 409 y perder el trabajo si no se le muestran las diferencias. Espera (latencia) pero no ve conflictos.
Casos de uso típicos Edición de fichas por humanos, agregados con poca contención, APIs REST con ETag. Saldos, stock de artículos muy demandados, numeración secuencial, colas, reparto de recursos escasos.
Regla práctica Optimista por defecto en todas las entidades editables por personas; pesimista en las pocas filas «calientes» donde la contención es la norma (saldo de una cuenta, contador de facturas, stock de la unidad más vendida); y escritura atómica relativa (SET x = x - ? con condición) cuando la operación es un simple incremento o decremento. Las tres técnicas conviven en la misma aplicación sin problema.

17.5.6 Deadlocks: cómo se producen y cómo convivir con ellos

Un deadlock es un ciclo de esperas: A espera un recurso que tiene B y B espera un recurso que tiene A. Ninguna avanza, así que el motor elige una víctima y la aborta con un error específico (40P01 en PostgreSQL, 1213 en MySQL).

  T1: transferir de A a B            T2: transferir de B a A
  ──────────────────────────────────────────────────────────────────
  BEGIN                              BEGIN
  SELECT A FOR UPDATE   (tiene A)
                                     SELECT B FOR UPDATE   (tiene B)
  SELECT B FOR UPDATE   ── espera a T2 ──┐
                                     SELECT A FOR UPDATE  ── espera a T1 ──┐
                                                                            │
        ┌───────────────────────────────────────────────────────────────────┘
        └── CICLO: el motor aborta una de las dos (deadlock detected)
  Solución: ordenar SIEMPRE los bloqueos por un criterio estable (el id).
  Con orden estable, T2 también pediría A antes que B: esperaría, pero no se
  formaría el ciclo.
src/database/reintentar-transaccion.ts
import { DriverException, OptimisticLockError } from '@mikro-orm/core';
// Códigos SQLSTATE que SIEMPRE merecen reintento: son conflictos, no errores de datos.
const REINTENTABLES = new Set([
  '40001',   // serialization failure (PostgreSQL: SERIALIZABLE / REPEATABLE READ)
  '40P01',   // deadlock detected (PostgreSQL)
]);
function esReintentable(e: unknown): boolean {
  if (e instanceof OptimisticLockError) return true;
  const code = (e as DriverException)?.code ?? (e as { code?: string }).code;
  // MySQL usa códigos numéricos: 1213 deadlock, 1205 lock wait timeout.
  return REINTENTABLES.has(String(code)) || ['1213', '1205'].includes(String(code));
}
export async function conReintentos<T>(op: () => Promise<T>, intentos = 3): Promise<T> {
  let ultimo: unknown;
  for (let i = 0; i < intentos; i++) {
    try {
      return await op();
    } catch (e) {
      if (!esReintentable(e)) throw e;    // un error de negocio no se reintenta
      ultimo = e;
      const espera = 25 * 2 ** i + Math.random() * 50;   // backoff + jitter
      await new Promise((r) => setTimeout(r, espera));
    }
  }
  throw ultimo;
}
// Uso: cada intento vuelve a abrir la transacción y a releer los datos.
await conReintentos(() => this.transferencias.transferir(a, b, 100));
Nunca reintentes dentro de la misma transacción Cuando el motor aborta una transacción, esa transacción está muerta: cualquier instrucción posterior falla con current transaction is aborted. El reintento tiene que envolver a em.transactional() por fuera, y cada intento debe releer las entidades desde cero (un fork nuevo o un em.clear()), porque el Identity Map del intento anterior contiene datos obsoletos.

17.5.7 Idempotencia y claves de idempotencia

Reintentar solo es seguro si repetir la operación produce el mismo resultado que ejecutarla una vez. Y hay que reintentar en muchos sitios: el cliente pulsa dos veces, el móvil pierde la cobertura y repite el POST, el balanceador reenvía, la cola entrega el mensaje dos veces. La solución estándar es que el cliente envíe una clave de idempotencia y que el servidor la guarde con una restricción de unicidad.

src/entities/operacion-idempotente.entity.ts
@Entity()
@Unique({ properties: ['clave'] })
export class OperacionIdempotente {
  @PrimaryKey() id!: string;
  @Property() clave!: string;            // cabecera Idempotency-Key generada por el cliente
  @Property() endpoint!: string;
  @Property() huellaPeticion!: string;   // detecta claves reutilizadas con otro cuerpo
  @Property({ type: 'json', nullable: true }) respuesta?: unknown;
  @Property() creadoEn: Date = new Date();
}
pagos.service.ts · escritura idempotente
async cobrar(clave: string, dto: CobroDto): Promise<RespuestaCobro> {
  return this.em.transactional(async (em) => {
    const huella = sha256(JSON.stringify(dto));
    // 1. ¿Ya se procesó esta clave? Devuelve la MISMA respuesta.
    const previa = await em.findOne(OperacionIdempotente, { clave });
    if (previa) {
      if (previa.huellaPeticion !== huella) {
        throw new ConflictException('La clave de idempotencia se reutilizó con otro cuerpo');
      }
      return previa.respuesta as RespuestaCobro;
    }
    // 2. Reserva la clave DENTRO de la transacción: si dos peticiones
    //    simultáneas llegan aquí, la restricción única hace fallar a una
    //    (error 23505) y esa devuelve 409 o reintenta la lectura del paso 1.
    const registro = em.create(OperacionIdempotente,
      { clave, endpoint: 'POST /pagos', huellaPeticion: huella });
    // 3. Trabajo real, en la misma transacción.
    const pago = em.create(Pago, { importe: dto.importe, pedido: dto.pedidoId });
    const respuesta: RespuestaCobro = { pagoId: pago.id, estado: 'ACEPTADO' };
    registro.respuesta = respuesta;
    return respuesta;
  });
}
Idempotencia y efectos externos La clave de idempotencia protege tu base de datos, no la pasarela de pago. Si dentro de la operación llamas a un tercero, necesitas propagar la idempotencia: casi todas las pasarelas serias aceptan una cabecera Idempotency-Key. Y ese tipo de llamada no debe estar dentro de la transacción: el patrón correcto es registrar la intención de forma atómica (tabla de salida) y ejecutar el efecto externo después, con reintentos idempotentes.

17.6 Esquema y migraciones

17.6.1 SchemaGenerator: útil en desarrollo, prohibido en producción

MikroORM sabe deducir el esquema de la base de datos a partir de los metadatos de tus entidades y compararlo con el esquema real. El objeto que hace ese trabajo es el SchemaGenerator, accesible como orm.schema y expuesto en la CLI como los comandos schema:*.

MétodoCLIQué haceDónde es legítimo
ensureDatabase()schema:create --run (implícito)Crea la base de datos si no existe.Arranque de entorno local y de tests.
createSchema()schema:create --runGenera todas las tablas desde cero.Base de datos vacía en local o en CI.
updateSchema()schema:update --runCalcula la diferencia y la aplica.Solo desarrollo local, y con reservas.
dropSchema()schema:drop --runElimina tablas (y opcionalmente la propia base).Limpieza entre suites de test.
refreshDatabase()schema:fresh --rundrop + create en un paso.Preparación de tests de integración.
clearDatabase()Vacía el contenido conservando las tablas.Entre tests: mucho más rápido que recrear.
test/setup.ts · el uso legítimo del SchemaGenerator
let orm: MikroORM;
beforeAll(async () => {
  orm = await MikroORM.init(configTest);        // base de datos de test dedicada
  await orm.schema.refreshDatabase();           // esquema limpio desde las entidades
});
beforeEach(async () => {
  await orm.schema.clearDatabase();   // vacía datos y conserva estructura: más rápido
  orm.em.clear();                     // Identity Map limpio entre tests
});
afterAll(() => orm.close(true));
Por qué updateSchema no se usa nunca en producción
  • Ejecuta operaciones destructivas. Si renombras una propiedad de la entidad, el diff no ve un renombrado: ve una columna que sobra y otra que falta. Genera DROP COLUMN + ADD COLUMN y los datos desaparecen sin previo aviso.
  • No hay control de versiones. No queda registro de qué se aplicó, ni cuándo, ni por quién; no se puede revisar en un pull request ni revertir con criterio.
  • No es reproducible. El SQL depende del estado actual de esa base de datos, así que preproducción y producción pueden divergir silenciosamente.
  • Bloquea. Un ALTER TABLE generado automáticamente sobre una tabla de diez millones de filas puede tomar un bloqueo exclusivo durante minutos, con toda la aplicación esperando.
  • Se ejecuta N veces con N réplicas. Si lo llamas al arrancar la aplicación, cinco pods intentan modificar el esquema a la vez.

Regla: schema:update sirve para ver el SQL que hace falta (--dump) y como punto de partida de una migración. Nunca como mecanismo de despliegue.

17.6.2 Migraciones: configuración y comandos

Una migración es un fichero versionado, revisable y ordenado que describe un cambio de esquema con SQL explícito. El paquete @mikro-orm/migrations (o @mikro-orm/migrations-mongodb) añade el Migrator y sus comandos de CLI.

src/mikro-orm.config.ts
import { defineConfig } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { SeedManager } from '@mikro-orm/seeder';
export default defineConfig({
  // ...entidades, conexión
  extensions: [Migrator, SeedManager],      // en v6 las extensiones se registran así
  migrations: {
    tableName: 'mikro_orm_migrations',      // tabla de control de versiones
    path: './dist/migrations',              // JS compilado (producción)
    pathTs: './src/migrations',             // TS (desarrollo)
    glob: '!(*.d).{js,ts}',
    emit: 'ts',
    transactional: true,                    // cada migración en su transacción
    allOrNothing: true,                     // todas las pendientes en UNA transacción
    disableForeignKeys: false,              // en PostgreSQL requiere superusuario: déjalo en false
    dropTables: false,                      // sin DROP TABLE generados automáticamente
    safe: true,                             // sin DROP COLUMN generados automáticamente
    snapshot: true,                         // instantánea para calcular diffs incrementales
  },
});
ComandoQué haceNotas
migration:createGenera una migración con el diff entre entidades y esquema.Revísala siempre a mano antes de confirmarla.
migration:create --blankCrea una migración vacía.Para migraciones de datos, índices CONCURRENTLY o SQL que el diff no puede inferir.
migration:create --initialMigración inicial que asume el esquema ya existente.Para adoptar migraciones en un proyecto en marcha.
migration:upAplica las pendientes en orden.Acepta --to y --only para acotar.
migration:downRevierte la última (o hasta la indicada).Solo si el down existe y está probado.
migration:pendingLista lo que falta por aplicar.Ideal como comprobación previa al despliegue.
migration:listLista lo ya ejecutado.Lee la tabla de migraciones.
migration:freshBorra todo y aplica todas las migraciones desde cero.--seed para poblar. Jamás en producción.
migration:checkIndica con el código de salida si hay cambios sin migrar.Excelente gate en CI: falla si alguien tocó una entidad sin migración.
package.json · scripts
{
  "scripts": {
    "mikro": "mikro-orm --config ./src/mikro-orm.config.ts",
    "db:migration:create": "npm run mikro -- migration:create",
    "db:migration:up": "npm run mikro -- migration:up",
    "db:migration:pending": "npm run mikro -- migration:pending",
    "db:migrate:prod": "mikro-orm --config ./dist/mikro-orm.config.js migration:up"
  }
}

La tabla de migraciones (mikro_orm_migrations) es el estado: una fila por migración aplicada, con su nombre y su fecha. El Migrator compara los ficheros del directorio con esas filas para decidir qué está pendiente. De ahí dos consecuencias operativas: nunca renombres ni edites una migración ya aplicada (deja de reconocerse y se intenta aplicar de nuevo), y nunca borres filas de esa tabla a mano salvo que sepas exactamente qué estás reparando.

17.6.3 Anatomía de una migración

src/migrations/Migration20260615103000_añadir_estado_pedido.ts
import { Migration } from '@mikro-orm/migrations';
export class Migration20260615103000 extends Migration {
  // up: el cambio hacia adelante.
  async up(): Promise<void> {
    this.addSql(`alter table "pedido" add column "estado" varchar(20) null;`);
    this.addSql(`update "pedido" set "estado" = 'PENDIENTE' where "estado" is null;`);
    this.addSql(`create index "pedido_estado_idx" on "pedido" ("estado");`);
  }
  // down: la vuelta atrás, en orden inverso.
  async down(): Promise<void> {
    this.addSql(`drop index "pedido_estado_idx";`);
    this.addSql(`alter table "pedido" drop column "estado";`);
  }
}
Por qué el down debe existir y probarse

No es para «volver atrás en producción»: en producción revertir un esquema con datos nuevos suele ser imposible sin pérdida. Es para tres cosas muy concretas: (1) iterar en local sin recrear la base entera; (2) validar en CI que la migración es reversible, lo que obliga a pensar en qué datos se destruyen; (3) documentar de forma ejecutable qué introdujo el cambio. Una migración con down vacío es una declaración de que el cambio es irreversible, y eso debe ser una decisión consciente y comentada, no un olvido. Ponlo a prueba en CI con migration:up, migration:down y migration:up otra vez.

Algunas operaciones no pueden ejecutarse dentro de una transacción. La más frecuente es la creación de índices sin bloquear escrituras en PostgreSQL (CREATE INDEX CONCURRENTLY), que el motor rechaza en un bloque transaccional. La migración debe declararse como no transaccional.

src/migrations/Migration20260620090000_indice_concurrente.ts
export class Migration20260620090000 extends Migration {
  // Desactiva la transacción SOLO para esta migración.
  override isTransactional(): boolean {
    return false;
  }
  async up(): Promise<void> {
    // CONCURRENTLY no bloquea escrituras, pero tarda más y puede quedar
    // el índice en estado INVALID si falla: hay que comprobarlo después.
    this.addSql(`create index concurrently "factura_cliente_fecha_idx"
                   on "factura" ("cliente_id", "fecha");`);
  }
  async down(): Promise<void> {
    this.addSql(`drop index concurrently if exists "factura_cliente_fecha_idx";`);
  }
}
Comprobación posterior a un índice concurrente Si CREATE INDEX CONCURRENTLY falla a medias, PostgreSQL deja un índice inválido que ocupa espacio y no se usa. Se detecta con SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; y se corrige eliminándolo y volviéndolo a crear. Con allOrNothing: true recuerda que esta migración queda fuera de la transacción común: si el despliegue se aborta después, el índice ya está creado.

17.6.4 Migraciones sin tiempo de inactividad: expand/contract

Durante un despliegue hay un intervalo en el que convive código antiguo con código nuevo (despliegue progresivo, varias réplicas, rolling update de Kubernetes). Por tanto el esquema debe ser compatible con ambas versiones a la vez. El patrón que lo garantiza es expand/contract: primero se amplía el esquema de forma compatible, luego se migra el código y los datos, y solo al final se contrae eliminando lo viejo.

  Objetivo: renombrar "nombre_completo" en "nombre" + "apellidos".
  PASO 1 · EXPAND (migración)      añade columnas NULL, no toca nada más
  ┌──────────────────────────────────────────────────────────────────────┐
  │ nombre_completo  │ nombre (null)  │ apellidos (null)                 │
  └──────────────────────────────────────────────────────────────────────┘
     código v1 (lee/escribe nombre_completo)  ✔ sigue funcionando
  PASO 2 · DOBLE ESCRITURA (despliegue de código v2)
     v2 escribe en nombre_completo Y en nombre/apellidos; lee nombre_completo
     └── v1 y v2 conviven ✔ · esperar a que TODAS las réplicas sean v2
  PASO 3 · RELLENO (script por lotes, fuera de la transacción de despliegue)
     UPDATE ... SET nombre=..., apellidos=... WHERE nombre IS NULL  (en tandas)
     └── idempotente: se puede repetir y reanudar
  PASO 4 · CAMBIO DE LECTURA (despliegue de código v3)
     v3 lee nombre/apellidos y sigue escribiendo en ambos sitios
     └── ventana de seguridad: si v3 falla, se vuelve a v2 sin pérdida
  PASO 5 · DEJAR DE ESCRIBIR LO VIEJO (despliegue de código v4)
     v4 ignora nombre_completo por completo
  PASO 6 · CONTRACT (migración, días después)
     ALTER TABLE ... DROP COLUMN nombre_completo;  + NOT NULL en las nuevas
  ────────────────────────────────────────────────────────────────────────
  Cada paso es desplegable y reversible por separado: nunca hay un instante en el
  que el esquema y el código en ejecución sean incompatibles.
Migration...renombrar.tsINCORRECTO
async up(): Promise<void> {
  // Renombrado "atómico": rompe el código antiguo
  // en el instante en que se aplica.
  this.addSql(`alter table "cliente"
    rename column "nombre_completo" to "nombre";`);
  this.addSql(`alter table "cliente"
    add column "apellidos" varchar(120) not null;`);
}
// Consecuencias durante el rolling update:
//  · las réplicas v1 fallan con "column nombre_completo
//    does not exist" en CADA consulta
//  · NOT NULL sin default hace fallar la propia
//    migración si la tabla ya tiene filas
Migration...expand.tsCORRECTO
async up(): Promise<void> {
  // Paso 1: solo añadir, siempre nullable y sin
  // reescribir la tabla. Compatible con v1 y v2.
  this.addSql(`alter table "cliente"
    add column "nombre" varchar(60) null;`);
  this.addSql(`alter table "cliente"
    add column "apellidos" varchar(120) null;`);
}
async down(): Promise<void> {
  this.addSql(`alter table "cliente" drop column "nombre";`);
  this.addSql(`alter table "cliente" drop column "apellidos";`);
}
CambioRiesgo si se hace de golpeReceta segura
Añadir columna Bajo si es NULL y sin valor por defecto volátil. Añádela NULL. En PostgreSQL moderno un DEFAULT constante no reescribe la tabla; un DEFAULT con función sí puede hacerlo.
Renombrar columna Rompe el código antiguo inmediatamente. Expand/contract completo (los seis pasos del diagrama).
Cambiar de tipo Reescritura completa de la tabla con bloqueo exclusivo; posible pérdida por truncamiento. Columna nueva con el tipo nuevo, doble escritura, relleno por lotes, cambio de lectura, eliminación de la antigua.
Añadir índice en tabla grande CREATE INDEX normal bloquea las escrituras hasta terminar. CREATE INDEX CONCURRENTLY en una migración no transaccional; en MySQL 8, DDL en línea.
Añadir NOT NULL Escaneo completo con bloqueo exclusivo, y fallo si hay nulos. Rellenar primero por lotes; luego CHECK (col IS NOT NULL) NOT VALID, VALIDATE CONSTRAINT (no bloquea lecturas) y por último SET NOT NULL.
Añadir clave foránea Valida todas las filas existentes con bloqueo. ADD CONSTRAINT ... NOT VALID y después VALIDATE CONSTRAINT.
Eliminar columna Rompe cualquier réplica antigua y cualquier SELECT *. Último paso del contract, cuando ninguna versión desplegada la menciona.
SQL de las operaciones seguras en PostgreSQL
-- 1. Añadir columna: instantáneo, no reescribe la tabla
ALTER TABLE "cliente" ADD COLUMN "nombre" varchar(60) NULL;
-- 2. Relleno por lotes: nunca en un solo UPDATE de 20 millones de filas
UPDATE "cliente" SET "nombre" = split_part("nombre_completo", ' ', 1)
 WHERE "id" IN (
   SELECT "id" FROM "cliente" WHERE "nombre" IS NULL ORDER BY "id" LIMIT 5000
 );
-- repetir hasta que afecte a 0 filas, con una pausa entre tandas
-- 3. NOT NULL en dos fases, sin bloqueo largo de escritura
ALTER TABLE "cliente" ADD CONSTRAINT "cliente_nombre_no_nulo"
  CHECK ("nombre" IS NOT NULL) NOT VALID;     -- inmediato: no valida lo existente
ALTER TABLE "cliente" VALIDATE CONSTRAINT "cliente_nombre_no_nulo";  -- escanea sin bloquear lecturas
ALTER TABLE "cliente" ALTER COLUMN "nombre" SET NOT NULL;            -- ya es barato
ALTER TABLE "cliente" DROP CONSTRAINT "cliente_nombre_no_nulo";
-- 4. Índice sin bloquear escrituras (fuera de transacción)
CREATE INDEX CONCURRENTLY "cliente_nombre_idx" ON "cliente" ("nombre");
-- 5. Protección universal contra ALTER que se queda esperando un bloqueo
SET lock_timeout = '3s';        -- si no consigue el bloqueo en 3 s, falla en vez de encolar
SET statement_timeout = '30s';
La cola de bloqueos: el fallo que tumba la aplicación entera Un ALTER TABLE necesita un bloqueo exclusivo. Si hay una consulta larga en curso, el ALTER se pone a la cola, y todas las consultas posteriores sobre esa tabla se ponen detrás de él, aunque solo lean. Una migración que «debería tardar 5 ms» acaba dejando la tabla inaccesible durante minutos. Por eso lock_timeout en las migraciones no es un lujo: es obligatorio.

17.6.5 Migraciones de datos frente a migraciones de esquema

Migración de esquemaMigración de datos
Qué cambiaEstructura: tablas, columnas, índices, restricciones.Contenido: rellenar, normalizar, corregir, reclasificar.
DuraciónIdealmente milisegundos.Puede ser de horas.
TransacciónSí, una por migración.Por lotes, con commit por lote.
ReejecutableNo: la controla la tabla de migraciones.Debe ser idempotente y reanudable.
Dónde vivesrc/migrations.Script o comando de CLI aparte, invocable a mano.

Un relleno pequeño (unas miles de filas, tiempo previsible) puede ir dentro de la migración. Sácalo a un script aparte cuando se cumpla cualquiera de estas condiciones: afecta a más de unas decenas de miles de filas, necesita lógica de negocio de la aplicación (no expresable en SQL), debe ejecutarse por tandas con pausas, o puede necesitar reanudarse tras un fallo. Una migración que tarda una hora bloquea el despliegue durante una hora y, con allOrNothing, mantiene una transacción gigante abierta.

src/scripts/rellenar-nombres.ts · migración de datos reanudable
// Ejecución: node dist/scripts/rellenar-nombres.js
// Idempotente: procesa solo lo que falta, se puede parar y reanudar.
const orm = await MikroORM.init();
const em = orm.em.fork();
let total = 0;
for (;;) {
  const lote = await em.find(Cliente, { nombre: null }, { limit: 5000, orderBy: { id: 'asc' } });
  if (lote.length === 0) break;
  for (const c of lote) {
    const [nombre, ...resto] = c.nombreCompleto.split(' ');
    c.nombre = nombre;
    c.apellidos = resto.join(' ') || '-';
  }
  await em.flush();     // un COMMIT por lote: transacciones cortas
  em.clear();           // libera el Identity Map: memoria constante
  console.log(`rellenados ${(total += lote.length)}`);
  await new Promise((r) => setTimeout(r, 100));   // deja respirar a la base de datos
}
await orm.close(true);

17.6.6 Migraciones en CI/CD

  git push
     ▼
  ┌─────────────────────────── CI ────────────────────────────┐
  │ build · lint · test                                       │
  │ migration:check   ← ¿alguien tocó una entidad sin migrar? │
  │ up → down → up    ← el down funciona de verdad            │
  └─────────────────────────┬─────────────────────────────────┘
                            ▼
  ┌───────────────── DESPLIEGUE (una sola vez) ───────────────┐
  │ Job / init container / release phase:                     │
  │   1. pg_try_advisory_lock  ← evita ejecuciones simultáneas │
  │   2. migration:up                                         │
  │   3. libera el bloqueo                                    │
  └─────────────────────────┬─────────────────────────────────┘
                            ▼
  ┌──────────── ARRANQUE DE LAS RÉPLICAS DE LA APP ───────────┐
  │ N pods · NINGUNO ejecuta migraciones · solo migration:    │
  │ pending como comprobación de salud al arrancar            │
  └───────────────────────────────────────────────────────────┘
src/scripts/migrar.ts · migración con bloqueo exclusivo
const orm = await MikroORM.init();
const conexion = orm.em.getConnection();
// Identificador arbitrario pero fijo para "migraciones de esta aplicación".
const [{ bloqueado }] = await conexion.execute<{ bloqueado: boolean }[]>(
  'select pg_try_advisory_lock(918273645) as bloqueado');
if (!bloqueado) {
  console.error('Otro proceso está migrando. Abortando sin error.');
  await orm.close(true);
  process.exit(0);          // no es un fallo: alguien más se está encargando
}
try {
  const pendientes = await orm.migrator.getPendingMigrations();
  console.log(`Aplicando ${pendientes.length} migraciones`);
  await orm.migrator.up();
} finally {
  await conexion.execute('select pg_advisory_unlock(918273645)');
  await orm.close(true);
}
Qué hacer si una migración falla a medias
  1. No la relances a ciegas. Mira primero la tabla mikro_orm_migrations: te dice qué se aplicó realmente.
  2. Si era transaccional (el caso normal en PostgreSQL, que soporta DDL transaccional), la base de datos ya deshizo el cambio: corrige el SQL y vuelve a desplegar. En MySQL, en cambio, el DDL no es transaccional: la mitad de los ALTER pueden haberse aplicado.
  3. Si quedó a medias, escribe una migración nueva de reparación en lugar de editar la fallida: inspecciona el estado real, hazla idempotente (IF NOT EXISTS, IF EXISTS) y déjala en el historial. Editar una migración ya registrada oculta el problema y rompe los entornos donde sí funcionó.
  4. Si el despliegue del código ya salió y el esquema no está listo, la vuelta atrás del código debe ser posible sin vuelta atrás del esquema: eso es precisamente lo que garantiza expand/contract.
  5. Antes de tocar producción a mano, ten una copia de seguridad reciente y verificada. Una restauración probada vale más que cualquier procedimiento improvisado.

17.7 Seeders y datos de prueba

Un entorno sin datos no se puede demostrar ni probar. @mikro-orm/seeder aporta dos piezas: el Seeder (un guion que puebla la base de datos, componible con otros seeders) y la Factory (una plantilla que genera entidades con datos verosímiles y permite sobrescribir lo que importa en cada test).

src/seeders/usuario.factory.ts
import { Factory } from '@mikro-orm/seeder';
import { faker } from '@faker-js/faker/locale/es';
import { Usuario } from '../entities/usuario.entity';
export class UsuarioFactory extends Factory<Usuario> {
  model = Usuario;
  // Valores por defecto verosímiles: todo lo que el test no fija.
  definition(): Partial<Usuario> {
    return {
      nombre: faker.person.firstName(), apellidos: faker.person.lastName(),
      email: faker.internet.email().toLowerCase(), activo: true,
      creadoEn: faker.date.past({ years: 2 }),
    };
  }
}
src/seeders/database.seeder.ts
import { EntityManager } from '@mikro-orm/core';
import { Seeder } from '@mikro-orm/seeder';
export class DatabaseSeeder extends Seeder {
  async run(em: EntityManager): Promise<void> {
    // Datos deterministas primero: los que la aplicación necesita para arrancar.
    em.create(Rol, { codigo: 'ADMIN', nombre: 'Administrador' });
    em.create(Rol, { codigo: 'USER',  nombre: 'Usuario' });
    // Un usuario fijo y conocido para poder entrar a mano en desarrollo.
    new UsuarioFactory(em).makeOne({ email: 'admin@ejemplo.local', nombre: 'Admin' });
    // Volumen para ver la paginación y medir consultas.
    new UsuarioFactory(em).each((u) => { u.activo = Math.random() > 0.1; }).make(200);
    // Relaciones: crea el agregado completo desde la factoría del padre.
    new PedidoFactory(em).make(50);
    // El SeedManager hace flush al terminar; llamar a otros seeders:
    // return this.call(em, [CatalogoSeeder, PromocionesSeeder]);
  }
}
terminal
npx mikro-orm seeder:create DatabaseSeeder   # andamiaje de un seeder nuevo
npx mikro-orm seeder:run                     # ejecuta el defaultSeeder de la configuración
npx mikro-orm seeder:run --class=CatalogoSeeder
npx mikro-orm migration:fresh --seed         # local desde cero: esquema + migraciones + datos
Datos de desarrolloDatos de test
ObjetivoPoder usar la aplicación y ver casos variados.Probar un comportamiento concreto.
VolumenCientos o miles de filas.El mínimo imprescindible.
DeterminismoAleatorio está bien (con credenciales fijas conocidas).Obligatorio: sin aleatoriedad no controlada. Fija la semilla de faker.
Quién lo creaUn seeder global.Factorías dentro de cada test, con makeOne({...}).
AntipatrónDepender de un volcado de producción.Un seeder compartido enorme: los tests se acoplan entre sí y fallan en cascada.
Anonimización de datos de producción

Copiar la base de producción a preproducción es cómodo y es una de las formas más habituales de infringir el RGPD: los datos personales pasan a un entorno con menos controles, más accesos y a menudo con correo real saliente. Si necesitas volumen y distribución realistas, aplica una anonimización irreversible en el mismo proceso de copia y nunca en un paso manual posterior.

  • Sustituye nombres, direcciones y teléfonos por datos sintéticos, no por transformaciones reversibles.
  • Reescribe los correos a un dominio inexistente (usuario+123@ejemplo.invalid) para que sea imposible enviar nada a una persona real.
  • Sustituye los hashes de contraseña por el de una contraseña conocida de test.
  • Elimina, no ofusques, los datos de categoría especial (salud, biometría) y los medios de pago.
  • Conserva las distribuciones que importan para el rendimiento: número de filas, cardinalidad de las claves foráneas, tamaño de los campos de texto.
  • Deja registro del proceso: un script versionado, revisable y ejecutado automáticamente.

17.8 Filtros globales

Un filtro global es una condición que MikroORM añade automáticamente al WHERE de todas las consultas sobre una entidad, sin que tengas que escribirla. Resuelve el problema de las condiciones transversales: reglas que deben cumplirse en el 100 % de las consultas y que, escritas a mano, se olvidan exactamente donde más duele.

src/entities/documento.entity.ts · filtros a nivel de entidad
import { Entity, Filter, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
// 1. Borrado lógico: activo por defecto en todas las consultas.
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
// 2. Multi-inquilino: la condición es una función y args: true exige parámetros.
@Filter({ name: 'tenant', args: true, default: true,
          cond: (args: { tenantId: string }) => ({ tenant: args.tenantId }) })
// 3. Visibilidad por permisos: la condición puede depender del tipo de operación
//    ('read', 'update', 'delete'). Con default: false se activa por consulta.
@Filter({ name: 'soloPublicados', default: false,
          cond: (args, type) => (type === 'read' ? { estado: 'PUBLICADO' } : {}) })
export class Documento {
  @PrimaryKey() id!: string;
  @Property() titulo!: string;
  @Property() estado!: 'BORRADOR' | 'PUBLICADO';
  @Property() tenant!: string;
  @Property({ nullable: true }) deletedAt?: Date;
}
src/database/filtros.ts · filtros en la configuración y parámetros
// Definidos globalmente en la configuración: se aplican a las entidades indicadas.
export default defineConfig({
  filters: {
    softDelete: { cond: { deletedAt: null }, default: true, entity: ['Documento', 'Usuario'] },
  },
});
// Añadidos en tiempo de ejecución sobre un EM concreto (por ejemplo, en un middleware):
em.addFilter('tenant', (args: { tenantId: string }) => ({ tenant: args.tenantId }), ['Documento']);
// Los parámetros se fijan una vez por contexto y valen para toda la petición:
em.setFilterParams('tenant', { tenantId: peticion.tenantId });
// Y se desactivan o activan por consulta:
await em.find(Documento, {}, { filters: { softDelete: false } });   // incluye borrados
await em.find(Documento, {}, { filters: ['soloPublicados'] });      // activa uno no-default
await em.find(Documento, {}, { filters: false });                   // desactiva TODOS (peligroso)
SQL con y sin filtro
-- em.find(Documento, { estado: 'PUBLICADO' })   con softDelete y tenant activos
SELECT "d0".* FROM "documento" AS "d0"
 WHERE "d0"."estado" = 'PUBLICADO' AND "d0"."deleted_at" IS NULL AND "d0"."tenant" = 'acme';
-- em.find(Documento, { estado: 'PUBLICADO' }, { filters: false })
SELECT "d0".* FROM "documento" AS "d0" WHERE "d0"."estado" = 'PUBLICADO';
--  ← ninguna protección: ni borrado lógico ni aislamiento por inquilino
Alcance real de los filtros: compruébalo, no lo supongas

Los filtros se aplican a los métodos del EntityManager (find, findOne, count, carga de relaciones, nativeUpdate, nativeDelete). No se aplican a SQL crudo (em.getConnection().execute(...)), y su comportamiento con el QueryBuilder ha cambiado entre versiones. Antes de confiar en ellos como mecanismo de aislamiento, verifica con el registro de SQL activado que la condición aparece en las consultas de tu versión, incluidas las del QueryBuilder y las de las relaciones cargadas de forma perezosa.

La seguridad no puede depender solo de un filtro

Un filtro es una comodidad, no un control de acceso. Motivos:

  • Se puede desactivar por consulta, y un filters: false añadido para depurar puede acabar en producción.
  • No cubre SQL crudo, vistas, procedimientos, informes ni herramientas externas conectadas a la misma base de datos.
  • Depende de que los parámetros estén bien fijados: si setFilterParams no se ejecutó en esa ruta, la consulta puede fallar (mejor caso) o filtrar por undefined.

Defensa en profundidad para multi-inquilino: filtro global más comprobación explícita del tenantId en el caso de uso al cargar el agregado raíz, más políticas de seguridad a nivel de fila en la base de datos (ROW LEVEL SECURITY en PostgreSQL) cuando el dato es realmente sensible, más un test de integración que intente leer datos de otro inquilino y espere un error.

17.9 Soft delete (borrado lógico)

Borrar lógicamente es marcar la fila como eliminada en lugar de suprimirla. Se usa cuando el dato tiene valor histórico (facturas, movimientos), cuando hay que poder deshacer, cuando otras filas lo referencian o cuando una auditoría exige rastro. La implementación completa tiene más piezas de las que parece.

src/entities/base-borrable.entity.ts
// Clase base reutilizable: la columna, el filtro y las operaciones.
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
export abstract class BaseBorrable {
  @Property({ nullable: true, index: true }) deletedAt?: Date | null;
  @Property({ nullable: true }) deletedBy?: string | null;   // casi siempre se acaba necesitando
  get estaBorrado(): boolean { return this.deletedAt != null; }
}
src/documentos/documentos.service.ts
@Injectable()
export class DocumentosService {
  constructor(private readonly em: EntityManager) {}
  // BORRAR: no es em.remove(), es una actualización.
  async borrar(id: string, usuarioId: string): Promise<void> {
    const doc = await this.em.findOneOrFail(Documento, id);   // el filtro evita reborrar
    doc.deletedAt = new Date();
    doc.deletedBy = usuarioId;
    await this.em.flush();
  }
  // RESTAURAR: hay que desactivar el filtro para poder encontrarlo.
  async restaurar(id: string): Promise<void> {
    const doc = await this.em.findOneOrFail(Documento, id, { filters: { softDelete: false } });
    doc.deletedAt = null;
    doc.deletedBy = null;
    await this.em.flush();
  }
  // CONSULTAR INCLUYENDO BORRADOS: solo para administración o papelera.
  async listarPapelera(): Promise<Documento[]> {
    return this.em.find(Documento, { deletedAt: { $ne: null } },
      { filters: { softDelete: false }, orderBy: { deletedAt: 'desc' } });
  }
  // PURGAR de verdad: borrado físico pasados N días (retención).
  async purgar(dias = 30): Promise<number> {
    const limite = new Date(Date.now() - dias * 86_400_000);
    // Sin desactivar el filtro no borraría nada: exige deletedAt IS NULL.
    return this.em.nativeDelete(Documento, { deletedAt: { $lt: limite } },
      { filters: { softDelete: false } });
  }
}

17.9.1 El problema clásico: restricciones únicas y filas borradas

Si usuario.email es único y borras lógicamente a ana@ejemplo.com, la fila sigue existiendo: la restricción única impide que Ana vuelva a registrarse. Es el error que aparece siempre, semanas después de implementar el borrado lógico, y en forma de duplicate key value violates unique constraint incomprensible para el usuario.

usuario.entity.tsINCORRECTO
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null },
          default: true })
export class Usuario {
  @PrimaryKey() id!: string;
  // Unicidad global: cuenta también las filas borradas.
  @Property({ unique: true })
  email!: string;
  @Property({ nullable: true }) deletedAt?: Date;
}
// Ana se borra y quiere volver:
// ERROR: duplicate key value violates unique
//        constraint "usuario_email_unique"
usuario.entity.tsCORRECTO
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null },
          default: true })
// Índice único PARCIAL: solo aplica a las filas vivas.
// Se declara con una expresión para que la migración lo cree.
@Index({
  name: 'usuario_email_activo_unq',
  expression: `create unique index "usuario_email_activo_unq"
    on "usuario" ("email") where "deleted_at" is null`,
})
export class Usuario {
  @PrimaryKey() id!: string;
  @Property() email!: string;   // sin unique: lo aporta el índice parcial
  @Property({ nullable: true }) deletedAt?: Date;
}
Las tres soluciones en SQL
-- OPCIÓN A · Índice único parcial (PostgreSQL, SQLite). La más limpia.
CREATE UNIQUE INDEX "usuario_email_activo_unq" ON "usuario" ("email") WHERE "deleted_at" IS NULL;
-- OPCIÓN B · Columna generada + único compuesto (MySQL 8, que no tiene índices parciales).
--            NULL no rompe la unicidad, pero aquí necesitamos un valor discriminante.
ALTER TABLE "usuario"
  ADD COLUMN "borrado_token" varchar(40)
  GENERATED ALWAYS AS (IF("deleted_at" IS NULL, 'ACTIVO', CONCAT('DEL-', "id"))) STORED;
CREATE UNIQUE INDEX "usuario_email_token_unq" ON "usuario" ("email", "borrado_token");
-- OPCIÓN C · Liberar el valor al borrar (destructiva pero simple):
UPDATE "usuario" SET "deleted_at" = now(), "email" = concat('borrado+', "id", '@invalid')
 WHERE "id" = 'u-1';
-- Pierdes el email original: útil precisamente cuando además hay que anonimizar.

17.9.2 Claves foráneas, integridad y derecho de supresión

Borrado lógico y derecho de supresión (RGPD, artículo 17)

Marcar deletedAt no es suprimir datos personales: los datos siguen ahí, íntegros y consultables. Si un usuario ejerce su derecho de supresión, necesitas un procedimiento distinto:

  • Suprimir de verdad lo que no tenga base legal de conservación (perfil, preferencias, dispositivos, sesiones, ficheros subidos).
  • Anonimizar de forma irreversible lo que debas conservar por obligación legal o contable: una factura debe seguir existiendo, pero puede apuntar a un cliente «Usuario eliminado» sin datos identificativos.
  • Propagar la supresión a copias y derivados: caché, índices de búsqueda, almacenes de eventos, exportaciones, registros de auditoría, copias de seguridad (con su plazo documentado) y proveedores externos.
  • Dejar constancia de la supresión (fecha y alcance) sin conservar los datos suprimidos: se registra el hecho, no el contenido.

Traducción a diseño: el borrado lógico sirve para la papelera del producto; la supresión legal es un caso de uso aparte, con su propio código, sus propios tests y su propio registro. No los confundas.

17.10 Eventos y suscriptores

MikroORM permite enganchar código al ciclo de vida de las entidades de dos formas: hooks (decoradores en métodos de la propia entidad) y suscriptores (clases que implementan EventSubscriber y observan varias entidades). Los hooks son locales y sencillos; los suscriptores son globales, testeables por separado y pueden inyectarse dependencias.

EventoCuándo se disparaQué es seguro hacer
onInitAl crear la instancia: tanto al hidratar desde la base de datos como con em.create().Inicializar campos calculados en memoria. Nada asíncrono ni de base de datos: se ejecuta muchísimas veces.
onLoadDespués de cargar y hidratar por completo la entidad desde la base de datos.Enriquecer con datos derivados; admite código asíncrono. Cuidado: se ejecuta por cada entidad cargada.
beforeCreateAntes del INSERT, con el change set ya calculado.Rellenar valores derivados de la propia entidad (slug, checksum). Los cambios se incluyen en el INSERT.
afterCreateDespués del INSERT (la clave ya existe).Registrar, publicar eventos de dominio. Modificar la entidad aquí no se persiste en ese flush.
beforeUpdateAntes del UPDATE.Actualizar campos derivados. Se puede consultar el change set para saber qué cambió.
afterUpdateDespués del UPDATE.Efectos posteriores: invalidar caché, encolar trabajos.
beforeDelete / afterDeleteAlrededor del DELETE.Validaciones de última hora y limpieza de recursos externos (ficheros).
beforeFlushAl principio del flush, antes de calcular los change sets.El único sitio donde es totalmente seguro crear, modificar o borrar entidades: todavía entrarán en el cálculo.
onFlushChange sets ya calculados, antes de ejecutar el SQL.Inspeccionar y ajustar change sets; si añades entidades hay que recalcular explícitamente.
afterFlushCuando todo el SQL del flush se ha ejecutado.Solo lectura y efectos externos. Ya no puedes añadir nada a ese flush.
beforeTransactionStart, afterTransactionStart, beforeTransactionCommit, afterTransactionCommit, beforeTransactionRollback, afterTransactionRollbackAlrededor de las fronteras de la transacción.Instrumentación: métricas de duración, trazas, fijar variables de sesión (por ejemplo el inquilino para ROW LEVEL SECURITY).
src/entities/articulo.entity.ts · hooks locales
import { BeforeCreate, BeforeUpdate, Entity, EventArgs, OnInit } from '@mikro-orm/core';
@Entity()
export class Articulo {
  @PrimaryKey() id!: string;
  @Property() titulo!: string;
  @Property() slug!: string;
  @Property({ type: 'text' }) cuerpo!: string;
  @Property() palabras: number = 0;
  // Derivar datos de la propia entidad: uso legítimo y sin sorpresas.
  @BeforeCreate()
  @BeforeUpdate()
  recalcular(args: EventArgs<Articulo>): void {
    this.slug = slugify(this.titulo);
    this.palabras = this.cuerpo.trim().split(/\s+/).length;
    // El change set indica qué cambió realmente (útil para evitar trabajo).
    const cambios = args.changeSet?.payload;
    if (cambios && 'cuerpo' in cambios) this.revisionesCuerpo++;
  }
  @OnInit()
  inicializar(): void {
    this.slug ??= '';   // solo memoria: nada de await ni de consultas
  }
}
Los eventos no se disparan en las operaciones nativas nativeUpdate, nativeDelete, qb.update(), em.insertMany() y el SQL crudo no pasan por el Unit of Work: no hay hooks, no hay suscriptores, no hay columna de versión incrementada. Si tu lógica de auditoría o de invalidación de caché vive en un hook, cualquier escritura masiva la salta en silencio. Es la razón principal para no poner reglas importantes en los eventos.
pedido.entity.tsINCORRECTO
@Entity()
export class Pedido {
  // Lógica de negocio escondida en un hook:
  @AfterUpdate()
  async alActualizar(args: EventArgs<Pedido>) {
    if (this.estado === 'PAGADO') {
      // Efectos invisibles desde el caso de uso:
      await mailer.enviarFactura(this);       // no se deshace con ROLLBACK
      await almacen.reservarStock(this);      // ¿y si falla?
      await erp.sincronizar(this);            // 800 ms dentro del flush
    }
  }
}
// Problemas: no se dispara con nativeUpdate; sí se dispara al importar datos
// o al ejecutar un seeder; imposible de testear sin base de datos; el orden
// de los efectos depende del orden interno del flush.
pedidos.service.tsCORRECTO
@Injectable()
export class PedidosService {
  // El caso de uso es explícito y legible de arriba abajo.
  async marcarPagado(id: string): Promise<void> {
    await this.em.transactional(async (em) => {
      const p = await em.findOneOrFail(Pedido, id);
      p.marcarPagado();                       // invariantes en el dominio
      // Efectos externos: registrados de forma atómica,
      // ejecutados FUERA de la transacción (patrón outbox).
      em.create(EventoSaliente, { tipo: 'PedidoPagado', payload: { pedidoId: p.id } });
    });
  }
}
// Testeable sin base de datos, visible en la revisión,
// idempotente y con reintentos en el consumidor.
Qué sí es un buen uso de los eventos Auditoría y trazas (transversal, no negocio), sellos de tiempo y campos derivados, normalización de datos (recortar espacios, pasar correos a minúsculas), cifrado y descifrado de columnas, invalidación de caché, métricas e instrumentación, y validaciones defensivas de última línea. La prueba del algodón: si el comportamiento debe aparecer en la documentación funcional del caso de uso, no va en un hook.

17.11 Auditoría: quién cambió qué y cuándo

EnfoqueQué guardaVentajasInconvenientes
Columnas createdBy / updatedBy / updatedAt Solo el último autor y la última fecha. Trivial de implementar y de consultar; coste casi nulo. No hay historial: el cambio anterior se pierde.
Tabla de auditoría con el diff Una fila por cambio con el antes y el después de los campos afectados. Historial completo, consultable, con contexto de aplicación (usuario, IP, motivo). Crece rápido; hay que decidir retención; no captura cambios hechos fuera de la aplicación.
Triggers en la base de datos Lo mismo, pero escrito por el motor. Captura todo, incluidas las escrituras manuales y las masivas. No conoce el usuario de aplicación (hay que pasarlo por una variable de sesión); lógica fuera del repositorio; más difícil de versionar y de testear.
Event sourcing ligero Los eventos de dominio como fuente de verdad del historial. Semántica de negocio («pedido cancelado por falta de stock»), no diffs de columnas. Requiere diseño previo, versionado de eventos y proyecciones; sobredimensionado para un CRUD.
src/audit/auditoria.subscriber.ts
import { ChangeSetType, EventSubscriber, FlushEventArgs } from '@mikro-orm/core';
// El usuario actual viaja en AsyncLocalStorage (capítulo 11), no como parámetro.
@Injectable()
export class AuditoriaSubscriber implements EventSubscriber {
  constructor(em: EntityManager, private readonly ctx: ContextoPeticion) {
    em.getEventManager().registerSubscriber(this);
  }
  // Limitar el alcance evita auditar la propia tabla de auditoría (bucle infinito).
  getSubscribedEntities(): string[] { return ['Pedido', 'Producto', 'Usuario', 'Tarifa']; }
  // onFlush: los change sets ya están calculados y seguimos DENTRO del flush,
  // así que las filas de auditoría se escriben en la misma transacción.
  async onFlush(args: FlushEventArgs): Promise<void> {
    const autor = this.ctx.usuarioId ?? 'sistema';
    let nuevos = 0;
    for (const cs of args.uow.getChangeSets()) {
      const antes: Record<string, unknown> = {};
      const despues: Record<string, unknown> = {};
      if (cs.type === ChangeSetType.UPDATE) {
        for (const campo of Object.keys(cs.payload)) {
          if (CAMPOS_SENSIBLES.has(campo)) continue;      // nunca audites contraseñas
          antes[campo] = (cs.originalEntity as Record<string, unknown>)?.[campo];
          despues[campo] = (cs.payload as Record<string, unknown>)[campo];
        }
        if (Object.keys(despues).length === 0) continue;   // cambio irrelevante
      }
      args.em.create(RegistroAuditoria, { entidad: cs.name, entidadId: String(cs.entity.id),
        operacion: cs.type, antes, despues, autor, fecha: new Date(),
        peticionId: this.ctx.correlacionId });
      nuevos++;
    }
    // Las entidades creadas aquí no entran en los change sets ya calculados:
    // hay que pedir explícitamente que se recalculen.
    if (nuevos > 0) args.uow.computeChangeSets();
  }
}
Verifica el contrato de onFlush en tu versión El acceso a los change sets (uow.getChangeSets(), cs.payload, cs.originalEntity) y la necesidad de recalcular al crear entidades dentro del evento son detalles internos que MikroORM documenta, pero que han evolucionado entre versiones. Consulta la página de eventos de tu versión 6 y protege el comportamiento con un test de integración: es la única forma de detectar una regresión al actualizar. Si prefieres no depender de internals, la alternativa es un servicio de auditoría llamado explícitamente por cada caso de uso: más verboso, pero inmune a cambios del ORM.
SQL · la fila de auditoría resultante
BEGIN;
UPDATE "tarifa" SET "importe" = 120 WHERE "id" = 't1' AND "version" = 3;
INSERT INTO "registro_auditoria"
  ("entidad", "entidad_id", "operacion", "antes", "despues", "autor", "peticion_id", "fecha")
VALUES ('Tarifa', 't1', 'update', '{"importe":100}', '{"importe":120}',
        'u-42', 'a1b2c3', now());
COMMIT;   -- el cambio y su rastro se confirman juntos: nunca uno sin el otro

17.12 Rendimiento

17.12.1 Operaciones masivas

El Unit of Work está optimizado para el caso normal: unas decenas de entidades por transacción, con seguimiento de cambios, hooks y relaciones. Para medio millón de filas ese seguimiento es puro coste. MikroORM ofrece atajos que no pasan por el Unit of Work, y por eso son rápidos y por eso hay que usarlos con los ojos abiertos.

OperaciónQué haceCosteQué pierdes
em.persist() + flush()Change sets, agrupación por lotes, hooks, relaciones.Alto por entidad.Nada.
em.insertMany(E, datos)Un INSERT con varias tuplas; devuelve las claves.Mínimo.Hooks, eventos, Identity Map, versión, relaciones en cascada.
em.upsertMany(E, datos)INSERT ... ON CONFLICT DO UPDATE.Mínimo.Lo mismo. Ideal para sincronizaciones idempotentes.
em.nativeUpdate(E, where, datos)Un UPDATE masivo en el servidor.Mínimo.Hooks, versión, coherencia del Identity Map ya cargado.
em.nativeDelete(E, where)DELETE masivo.Mínimo.Cascadas de la ORM (las de la base de datos sí actúan).
importador.service.tsINCORRECTO
// Importar 500.000 filas de un CSV.
async importar(filas: FilaCsv[]) {
  for (const f of filas) this.em.create(Cliente, mapear(f));
  // Un único flush con 500.000 entidades:
  //  · el Identity Map guarda 500.000 objetos
  //  · el UoW guarda otras 500.000 instantáneas y calcula 500.000 diffs
  //  · una transacción gigantesca de varios minutos
  await this.em.flush();
}
// Resultado real: heap de Node por encima de 2 GB, "JavaScript heap out of
// memory" y una transacción que bloquea el autovacuum mientras dura.
importador.service.tsCORRECTO
// Por lotes, con memoria constante y transacciones cortas.
async importar(filas: AsyncIterable<FilaCsv>) {
  const em = this.em.fork();          // contexto propio y aislado
  let buffer: EntityData<Cliente>[] = [];
  for await (const f of filas) {
    buffer.push(mapear(f));
    if (buffer.length >= 2_000) {
      await em.insertMany(Cliente, buffer);   // sin UoW: rápido
      buffer = []; em.clear();                // libera el Identity Map
    }
  }
  if (buffer.length) await em.insertMany(Cliente, buffer);
}
// Memoria estable (~1 lote), transacciones cortas y progreso reanudable.
batchSize y agrupación de instrucciones Cuando el flush tiene que insertar o actualizar muchas entidades del mismo tipo, MikroORM agrupa las instrucciones en lotes cuyo tamaño controla la opción global batchSize (unos cientos por defecto). Subirlo reduce el número de idas y vueltas a la base de datos, pero aumenta el tamaño de cada sentencia y el consumo de memoria del driver; PostgreSQL además tiene un límite de parámetros por sentencia. Ajústalo midiendo (por ejemplo 500–2000 para importaciones) en lugar de a ojo, y recuerda que batchSize no elimina el coste del Unit of Work: solo el de la red.

17.12.2 El coste del Identity Map en procesos largos

El Identity Map es una tabla hash que garantiza «una fila, un objeto» dentro de un contexto. Su efecto secundario es que nada de lo que cargas se libera mientras el contexto viva: en una petición HTTP de 80 ms es exactamente lo que quieres; en un proceso que recorre un millón de filas es una fuga de memoria por diseño.

  Memoria del proceso durante un recorrido de 1.000.000 de filas
  sin em.clear()                        con em.clear() cada 2.000 filas
  ▲                                     ▲
  │                    ╱ OOM            │
  │                  ╱                  │
  │               ╱                     │  ▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂
  │            ╱                        │
  │       ╱╱                            │
  └──────────────────────────► tiempo   └──────────────────────────► tiempo
   Identity Map + instantáneas del UoW    memoria acotada por el lote

17.12.3 Pool de conexiones

src/mikro-orm.config.ts · pool
export default defineConfig({
  pool: {
    min: 2,                        // conexiones siempre listas: evita latencia en frío
    max: 10,                       // TOPE por instancia de la aplicación
    acquireTimeoutMillis: 5_000,   // fallar rápido si el pool está saturado
    idleTimeoutMillis: 30_000,     // devolver conexiones ociosas
  },
  driverOptions: {
    connection: {
      statement_timeout: 15_000,                     // ninguna consulta debe durar más
      idle_in_transaction_session_timeout: 10_000,   // mata transacciones olvidadas
    },
  },
});
  Presupuesto de conexiones (PostgreSQL max_connections = 100)
  ┌────────────────────────────────────────────────────────────────┐
  │ 100  total del servidor                                        │
  │  -3  superusuario reservado (superuser_reserved_connections)    │
  │ -10  migraciones, copias de seguridad, panel, analítica        │
  │ ───────────────────────────────────────────────────────────────│
  │ = 87 disponibles para la aplicación                            │
  │  87 / (réplicas de la API + trabajadores de colas + crons) =    │
  │  87 / (4 pods + 2 workers + 1 cron) ≈ 12  →  pool.max = 10      │
  └────────────────────────────────────────────────────────────────┘
  Más conexiones NO es más rendimiento: por encima de unas 2-4 veces el número
  de núcleos de la base de datos, el rendimiento total BAJA por contención.
  Si necesitas mucha concurrencia, pon un pooler (PgBouncer) delante.

17.12.4 Caché de resultados, caché de metadatos y arranque

src/mikro-orm.config.ts · cachés
export default defineConfig({
  // 1. CACHÉ DE RESULTADOS: opcional por consulta, nunca global sin pensarlo.
  //    adapter: RedisCacheAdapter → necesario si hay varias réplicas.
  resultCache: { expiration: 5_000 },   // valor por defecto en milisegundos
  // 2. CACHÉ DE METADATOS: acelera el arranque. En producción, generada en el build.
  metadataCache: { enabled: true },
  // 3. PROVEEDOR DE METADATOS: de dónde salen los tipos.
  // metadataProvider: TsMorphMetadataProvider,   // analiza el código fuente
});
// Uso de la caché de resultados por consulta:
const top = await em.find(Producto, { destacado: true }, { cache: 60_000 });
const menu = await em.find(Categoria, {}, { cache: ['menu-principal', 300_000] });
await em.clearCache('menu-principal');   // invalidación explícita al editar categorías
ReflectMetadataProviderTsMorphMetadataProvider
De dónde saca los tiposDe emitDecoratorMetadata en tiempo de ejecución.Analiza el código TypeScript (o los .d.ts) con la API del compilador.
VerbosidadHay que declarar el tipo explícitamente en los casos que el reflejo no distingue.Deduce casi todo del propio código.
ArranqueRápido: milisegundos.Lento: puede añadir segundos al primer arranque.
Requisitos en producciónNinguno especial.Necesita los ficheros de tipos junto al código compilado.
RecomendaciónPor defecto (es el predeterminado en la versión 6).Solo si su comodidad compensa; siempre con caché de metadatos generada en el build.
Arranque en producción: genera la caché en el build

El descubrimiento de entidades cuesta tiempo en cada arranque, y en un despliegue con escalado automático los arranques son frecuentes. La solución es generar la caché de metadatos como paso del build (la CLI tiene un comando cache:generate, con una variante que produce un único fichero combinado) e incluir ese fichero en la imagen. Con ella el arranque no vuelve a analizar entidades.

Comprueba en la documentación de tu versión el nombre exacto de la opción y del comando, porque cambiaron entre la versión 5 y la 6. Y ten en cuenta dos detalles: si empaquetas con un bundler, el descubrimiento por patrones de fichero no funciona (hay que registrar las entidades explícitamente), y una caché de metadatos obsoleta produce errores desconcertantes: bórrala en cada build.

La caché de resultados no es un parche para consultas lentas Cachear una consulta de 900 ms la convierte en una consulta de 900 ms que además devuelve datos viejos y que se dispara toda a la vez cuando la caché caduca (estampida). Primero mide y arregla la consulta (índices, N+1, proyección: capítulo 16); cachea después, solo lo que es caro y tolera desactualización, con clave explícita para poder invalidarlo, y con adaptador compartido si hay varias réplicas.

17.12.5 Lista de comprobación de rendimiento del ORM

  • Registro de SQL activado en desarrollo. Si no ves las consultas, no estás optimizando: estás adivinando.
  • Ninguna consulta dentro de un bucle. El N+1 es el problema número uno con diferencia (capítulo 16).
  • Proyección explícita (fields) en las listas grandes: no traigas columnas de texto ni JSON que nadie muestra.
  • Paginación siempre, con límite máximo impuesto por el servidor, no por el cliente.
  • Índices que cubran los WHERE y los ORDER BY reales, verificados con EXPLAIN ANALYZE, no supuestos.
  • Escrituras masivas con insertMany/nativeUpdate y procesamiento por lotes con em.clear().
  • Transacciones cortas y sin llamadas de red dentro.
  • Pool dimensionado con la aritmética de la sección 17.12.3, con statement_timeout y idle_in_transaction_session_timeout configurados.
  • Caché de metadatos generada en el build y arranque medido.
  • Métricas en producción: duración de consulta (percentil 95 y 99), consultas por petición, duración de transacción, conexiones en uso, tamaño del heap. Sin estas cinco, los problemas se descubren por Twitter.

17.13 Multi-tenancy

  A · BASE DE DATOS POR INQUILINO      B · ESQUEMA POR INQUILINO       C · COLUMNA DISCRIMINADORA
  ┌──────────┐  ┌──────────┐           ┌───────── una base ────────┐   ┌──────── una base ────────┐
  │ db_acme  │  │ db_globex│           │ schema acme │ schema glob │   │ tabla pedido             │
  │ ┌──────┐ │  │ ┌──────┐ │           │ ┌────────┐  │ ┌────────┐  │   │ id │ tenant_id │ total   │
  │ │pedido│ │  │ │pedido│ │           │ │ pedido │  │ │ pedido │  │   │  1 │ acme      │  120    │
  │ └──────┘ │  │ └──────┘ │           │ └────────┘  │ └────────┘  │   │  2 │ globex    │   80    │
  └──────────┘  └──────────┘           └─────────────┴─────────────┘   └──────────────────────────┘
  Aislamiento máximo                   Aislamiento medio               Aislamiento lógico
  Coste operativo máximo               Coste medio                     Coste mínimo
CriterioBase de datos por inquilinoEsquema por inquilinoColumna discriminadora
Aislamiento de datosTotal (físico).Alto (lógico fuerte).Depende del código: un WHERE olvidado es una fuga.
MigracionesN ejecuciones, una por inquilino; hay que orquestarlas y controlar fallos parciales.N ejecuciones sobre la misma conexión; más rápido, pero igual de N.Una sola. Ventaja enorme.
Coste por inquilinoAlto (conexiones, memoria, copias de seguridad).Medio.Casi nulo: escala a decenas de miles.
Copia, restauración o exportación de un inquilinoTrivial.Sencilla.Laboriosa: hay que recorrer todas las tablas.
Consultas entre inquilinos (métricas del producto)Difícil.Media.Trivial.
Ruido entre vecinosNinguno.Compartido pero acotable.Un inquilino grande afecta a todos.
Cuándo elegirlaPocos clientes grandes, requisitos regulatorios o de residencia de datos, contratos con aislamiento explícito.Punto intermedio: decenas o cientos de inquilinos con exigencia de separación.Opción por defecto en SaaS con muchos inquilinos pequeños.
src/tenancy/columna-discriminadora.ts · estrategia C
// 1. La entidad declara el filtro con argumentos.
@Entity()
@Filter({ name: 'tenant', cond: (a: { tenantId: string }) => ({ tenantId: a.tenantId }),
          args: true, default: true })
export class Pedido {
  @PrimaryKey() id!: string;
  @Property({ index: true }) tenantId!: string;   // índice: entra en TODAS las consultas
  @Property() total!: number;
}
// 2. Un middleware fija el inquilino al principio de cada petición.
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly em: EntityManager) {}
  use(req: Request, _res: Response, next: NextFunction): void {
    const tenantId = resolverInquilino(req);          // subdominio, JWT o cabecera
    if (!tenantId) throw new ForbiddenException('Inquilino no resuelto');
    this.em.setFilterParams('tenant', { tenantId });  // solo para ESTA petición
    next();
  }
}
// 3. Al crear entidades, el tenantId hay que ponerlo: el filtro solo filtra lecturas
//    y actualizaciones, no rellena valores. Un suscriptor beforeCreate es buen sitio.
src/tenancy/por-esquema.ts · estrategias A y B
// B · ESQUEMA POR INQUILINO: entidades con esquema comodín.
@Entity({ schema: '*' })
export class Pedido { /* ... */ }
// Un fork por petición con el esquema del inquilino resuelto:
const em = orm.em.fork();
em.schema = inquilino.esquema;               // afecta a las consultas de este contexto
const pedidos = await em.find(Pedido, {});   // SELECT ... FROM "acme"."pedido"
await orm.em.find(Pedido, {}, { schema: 'acme' });   // o bien por consulta
// Migraciones: una pasada por esquema, registrando el resultado de cada uno.
for (const t of await listarInquilinos()) await orm.migrator.up({ /* esquema de t */ });
// A · BASE DE DATOS POR INQUILINO: una instancia de MikroORM por inquilino,
// creadas de forma perezosa y con una caché con expiración para no agotar conexiones.
const orms = new Map<string, MikroORM>();
async function ormDe(inquilino: string): Promise<MikroORM> {
  if (!orms.has(inquilino)) {
    orms.set(inquilino, await MikroORM.init({ ...base, dbName: `db_${inquilino}`,
      pool: { min: 0, max: 2 } }));   // pool mínimo: son N instancias
  }
  return orms.get(inquilino)!;
}
Detalles de la API de esquemas El soporte de varios esquemas existe en MikroORM (entidades con schema: '*', opción schema por consulta y por contexto), pero los nombres exactos para fijar el esquema de un EntityManager forkeado y para dirigir el Migrator a un esquema concreto han cambiado entre versiones menores. Consulta la sección de multiple schemas de la documentación de tu versión 6 y cubre la decisión con un test que verifique que dos inquilinos no se ven entre sí.
Recomendación Empieza con columna discriminadora salvo que un requisito contractual o legal te obligue a otra cosa: es la única estrategia cuyo coste operativo no crece con el número de clientes. Diseña desde el primer día como si fueras a migrar (el tenantId en todas las tablas, resolución de inquilino centralizada, filtros más comprobación explícita) y, cuando llegue el cliente grande que exige base de datos propia, muévelo a la estrategia A sin cambiar el modelo. Lo que no funciona es mezclar estrategias sin una capa que las abstraiga.

17.14 Errores comunes y cómo solucionarlos

SíntomaCausaSolución
Parte del caso de uso se guarda aunque haya ROLLBACK Se usó this.em (el EM externo) dentro del callback de em.transactional(). Usar exclusivamente el em del parámetro; pasarlo como argumento explícito a los métodos auxiliares.
Using global EntityManager instance methods for context specific actions is disallowed Código sin contexto de petición: cron, consumidor de cola, script, escuchador de eventos. @CreateRequestContext() en el método, o orm.em.fork() / RequestContext.create() explícitos.
timeout acquiring a connection / la API se congela Conexiones retenidas: begin() sin rollback en alguna rama, o transacciones que esperan una llamada de red. em.transactional() en lugar de control manual; sacar la E/S externa de la transacción; idle_in_transaction_session_timeout; revisar pg_stat_activity.
Peticiones lentas y bloqueos en cascada al desplegar Transacción por petición que abarca serialización, validación y llamadas a terceros. Frontera transaccional en el caso de uso; excluir métodos seguros; vigilar la métrica de duración de transacción.
deadlock detected (40P01 / 1213) esporádico Dos rutas bloquean las mismas filas en orden distinto. Ordenar los bloqueos por un criterio estable; transacciones cortas; reintento con retroceso exponencial.
could not serialize access (40001) al subir el aislamiento SERIALIZABLE o REPEATABLE READ en PostgreSQL sin política de reintentos. Envolver la operación completa en un reintento idempotente, releyendo los datos en cada intento.
current transaction is aborted Se siguió usando la transacción después de un error del motor. Abandonar esa transacción; el reintento debe abrir una nueva y partir de un Identity Map limpio.
Dos usuarios guardan y uno pierde su cambio sin aviso Actualización perdida: leer, decidir en memoria y escribir sin control de concurrencia. Columna de versión (@Property({ version: true })) y respuesta 409, o escritura atómica relativa con condición en el WHERE.
Migración con DROP COLUMN inesperado Se aceptó sin revisar el diff de migration:create tras renombrar una propiedad. Revisar toda migración generada; safe: true y dropTables: false; expand/contract para renombrar.
Migración sin down: imposible iterar o revertir Se generó y nadie lo completó. Exigir down en la revisión de código y probar up → down → up en CI.
updateSchema borró datos en un entorno compartido Uso del SchemaGenerator fuera de local y de test. Prohibirlo por configuración y por revisión; migraciones versionadas y un usuario de aplicación sin permisos de DDL.
El esquema se migra N veces o falla con varias réplicas Migraciones ejecutadas en el arranque de la aplicación. Paso de despliegue único (Job o init container) con advisory lock.
Un ALTER TABLE trivial deja la tabla inaccesible El ALTER espera un bloqueo y encola tras él todas las consultas siguientes. lock_timeout corto en las migraciones y reintento; índices con CONCURRENTLY.
duplicate key value violates unique constraint al recuperar una cuenta borrada Borrado lógico con restricción única global. Índice único parcial (WHERE deleted_at IS NULL), columna generada en MySQL, o liberar el valor al borrar.
Las filas borradas lógicamente aparecen en un informe El filtro no cubre SQL crudo, vistas ni herramientas externas. Vistas de solo filas vivas, comprobación explícita en las consultas nativas y tests de aislamiento.
La auditoría o la caché no se actualizan en escrituras masivas nativeUpdate, insertMany y el SQL crudo no disparan hooks ni suscriptores. No dejar reglas importantes en los eventos; invalidar explícitamente tras la operación masiva, o usar triggers si debe capturarse todo.
JavaScript heap out of memory en una importación Identity Map e instantáneas del Unit of Work creciendo sin límite. Procesar por lotes con em.clear(), em.fork() por unidad de trabajo y insertMany para las inserciones.
Arranque de la aplicación de varios segundos Descubrimiento de entidades en cada arranque, agravado por TsMorphMetadataProvider. Caché de metadatos generada en el build; ReflectMetadataProvider salvo motivo fuerte.
Una entidad muestra valores antiguos tras un nativeUpdate El Identity Map conserva la versión anterior: la operación nativa no lo actualiza. em.refresh(entidad) o em.clear() después de la operación nativa.

17.15 Buenas y malas prácticas

Haz esto

  • Una transacción por caso de uso, abierta y cerrada en el servicio de aplicación, corta y sin E/S externa dentro.
  • Usa siempre el em del callback y pásalo explícitamente a los métodos auxiliares.
  • Protege las invariantes en la base de datos: restricciones únicas, CHECK, claves foráneas. El código se olvida; la restricción no.
  • Bloqueo optimista por defecto en entidades editables por personas; pesimista en las pocas filas calientes.
  • Reintentos idempotentes para deadlocks y fallos de serialización, siempre por fuera de la transacción.
  • Migraciones versionadas y revisadas, con down, probadas en CI y con lock_timeout.
  • Expand/contract para cualquier cambio incompatible; el esquema debe soportar dos versiones del código a la vez.
  • Migraciones en un paso de despliegue único con bloqueo, nunca en el arranque de la aplicación.
  • Lotes y em.clear() en cualquier proceso que recorra más de unos miles de filas.
  • Filtros globales como comodidad y comprobaciones explícitas como seguridad, con un test que intente cruzar la frontera entre inquilinos.
  • Mide en producción: duración de consulta y de transacción, conexiones en uso, memoria.

Evita esto

  • schema:update en cualquier entorno compartido. Es la vía más rápida a una pérdida de datos irreversible.
  • Dos flush() creyendo que forman una unidad atómica.
  • Llamar a una API externa, generar un PDF o enviar un correo dentro de una transacción.
  • Transacciones que esperan a un ser humano (bloqueo pesimista mientras un usuario rellena un formulario).
  • Subir el nivel de aislamiento «por si acaso» sin implementar reintentos.
  • Lógica de negocio en hooks de entidad: efectos invisibles que además se saltan en las operaciones nativas.
  • Editar o renombrar una migración ya aplicada en cualquier entorno.
  • Rellenos de datos de millones de filas dentro de una migración de esquema.
  • filters: false para «arreglar» una consulta que no encuentra un registro.
  • Confundir borrado lógico con supresión de datos personales.
  • Subir pool.max para tapar una fuga de conexiones.
  • Cachear resultados en lugar de arreglar la consulta y sus índices.

17.16 Preguntas frecuentes

¿Necesito em.transactional() si solo hago un flush()?
No. Un flush() ya se ejecuta dentro de su propia transacción, así que todos los cambios acumulados en el Unit of Work viajan juntos. em.transactional() hace falta cuando necesitas varios flush() en la misma unidad atómica, cuando quieres fijar el nivel de aislamiento, cuando hay lecturas con bloqueo pesimista (que exigen transacción abierta) o cuando el caso de uso debe abarcar también operaciones nativas.
¿Por qué el callback de transactional recibe un em si ya tengo this.em?
Porque una transacción necesita su propio contexto: un Identity Map y un Unit of Work independientes, ligados a la conexión donde se ejecutó el BEGIN. Si se reutilizara el contexto externo, un ROLLBACK dejaría el Identity Map lleno de entidades con valores que ya no existen en la base de datos, y cualquier trabajo pendiente del contexto externo se colaría en la transacción. El fork es lo que hace que el rollback sea limpio.
¿Bloqueo optimista o pesimista para el stock de un producto?
Depende de la contención. Para el catálogo general, optimista (o mejor, UPDATE stock = stock - ? WHERE id = ? AND stock >= ?, que resuelve el problema sin conflictos). Para una venta relámpago con miles de peticiones sobre la misma referencia, el bloqueo optimista degenera en un festival de reintentos: ahí es preferible pesimista, o sacar el reparto de unidades a una cola por producto para serializar el acceso sin bloquear la base de datos.
¿Puedo devolver 409 y reintentar automáticamente en el servidor?
Distingue el origen del conflicto. Si viene de dos personas editando el mismo recurso, reintentar en el servidor equivale a que el último sobrescriba al primero en silencio: devuelve 409 con el estado actual y deja decidir al usuario. Si viene de procesos automáticos que compiten por una fila, reintenta en el servidor con retroceso exponencial y jitter, releyendo la entidad en cada intento y limitando a tres o cuatro intentos.
¿Es aceptable usar schema:update en preproducción?
No, y por una razón práctica más allá del riesgo: preproducción sirve para ensayar el despliegue. Si ahí el esquema se actualiza con un mecanismo distinto al de producción, no estás ensayando nada. El mismo comando de migración, ejecutado por el mismo paso del pipeline, sobre datos de forma y volumen parecidos: eso es lo que da confianza el día del despliegue.
¿Y si necesito revertir una migración en producción?
Casi nunca es la respuesta correcta. Si la migración fue de expansión (añadir columnas, índices), lo natural es dejarla y desplegar hacia adelante el código corregido. Si fue de contracción (eliminó algo), el down puede recrear la estructura pero no los datos: por eso las contracciones se hacen días después, cuando ya no hay dudas. La estrategia real de recuperación no es «revertir el esquema», es «poder revertir el código sin tocar el esquema», que es exactamente lo que garantiza expand/contract.
¿Cada migración en su transacción o todas en una?
transactional: true por migración es imprescindible en cuanto el motor lo soporte. allOrNothing: true añade una transacción que envuelve a todas las pendientes: es lo que quieres en un despliegue, porque un fallo en la tercera deja la base de datos exactamente como estaba. Ten en cuenta dos excepciones: las migraciones no transaccionales (índices CONCURRENTLY) quedan fuera de esa garantía, y en MySQL el DDL no es transaccional, así que allOrNothing no puede protegerte igual.
¿Los seeders valen para producción?
Para datos de referencia imprescindibles (roles, países, tipos de IVA, estados de un flujo) sí, aunque es más robusto que esos datos entren por una migración idempotente (INSERT ... ON CONFLICT DO NOTHING): así quedan versionados y no dependen de que alguien ejecute un comando. Para datos de ejemplo, jamás: un seeder de desarrollo ejecutado por error en producción puede crear usuarios con contraseñas conocidas.
¿Un filtro global me protege contra fugas entre inquilinos?
Reduce mucho el riesgo, pero no es un control de seguridad. Se puede desactivar por consulta, no cubre SQL crudo ni vistas, y depende de que los parámetros se hayan fijado en esa ruta. La defensa correcta es por capas: filtro global, comprobación explícita del inquilino al cargar el agregado raíz, políticas a nivel de fila en la base de datos si el dato es sensible, y un test de integración que intente leer datos de otro inquilino y espere un fallo.
¿Cómo consulto entidades borradas lógicamente sin desactivar el filtro en todas partes?
Desactívalo solo en los métodos que lo necesitan (papelera, restauración, informes de administración) usando { filters: { softDelete: false } }, y encapsúlalos en un repositorio con nombres explícitos (buscarIncluyendoBorrados). Lo que no debes hacer es desactivar el filtro de forma global «para simplificar»: en cuanto lo haces, el borrado lógico deja de existir en la práctica y las filas borradas empiezan a aparecer en la interfaz.
¿El borrado lógico cumple con el derecho de supresión del RGPD?
No. Marcar una fila como borrada mantiene los datos personales intactos y accesibles. La supresión exige eliminar realmente lo que no tenga base legal de conservación y anonimizar de forma irreversible lo que deba conservarse (una factura sigue siendo obligatoria, pero puede apuntar a un cliente anonimizado). Además hay que propagarlo a cachés, índices de búsqueda, exportaciones y proveedores externos, y registrar el hecho de la supresión sin conservar su contenido.
¿Por qué mis hooks no se ejecutan en algunas escrituras?
Porque esas escrituras no pasan por el Unit of Work: nativeUpdate, nativeDelete, insertMany, el QueryBuilder de actualización y el SQL crudo van directas al servidor. Es intencionado (es lo que las hace rápidas) y es la razón principal para no colocar reglas de negocio en hooks. Si un comportamiento debe cumplirse siempre, su sitio es una restricción de la base de datos, un trigger o el propio caso de uso.
¿Cuál es el tamaño correcto del pool de conexiones?
El que resulte de dividir las conexiones disponibles del servidor (las totales menos las reservadas para administración, migraciones y copias de seguridad) entre todos los procesos que conectan: réplicas de la API, trabajadores de colas y crons. Suele salir un número sorprendentemente pequeño, entre 5 y 20 por instancia. Más conexiones no dan más rendimiento: por encima de unas pocas veces el número de núcleos de la base de datos, el rendimiento total baja. Si necesitas mucha concurrencia, la respuesta es un pooler como PgBouncer, no un max mayor.
¿Debo activar la caché de resultados de forma global?
No. Actívala consulta por consulta, solo donde el resultado sea caro de calcular, se lea mucho y tolere estar desactualizado (menús, catálogos, agregados de portada). Usa clave explícita para poder invalidar al escribir, y un adaptador compartido (Redis) si hay varias réplicas: con caché en memoria por proceso, cada réplica mostrará una versión distinta. Y antes de cachear, arregla la consulta: cachear una consulta lenta solo esconde el problema y añade el riesgo de estampida.
¿Puedo tener transacciones distribuidas entre la base de datos y una cola?
En la práctica, no: el commit en dos fases entre sistemas heterogéneos es frágil y casi nadie lo usa. El patrón habitual es outbox: dentro de la misma transacción de negocio insertas una fila en una tabla de eventos salientes, y un proceso aparte la lee y publica el mensaje con reintentos. Ganas atomicidad real (nunca hay evento sin cambio ni cambio sin evento) a cambio de que la entrega sea «al menos una vez», lo que obliga a que los consumidores sean idempotentes.

17.17 Ejercicios

Nivel 1 · básico

17.1 Escribe un caso de uso que cree un pedido con tres líneas y descuente stock, primero con dos flush() separados y después con em.transactional(). Provoca un error entre ambos flush() y comprueba en la base de datos qué queda en cada versión.

17.2 Añade @Property({ version: true }) a una entidad, activa el registro de SQL y describe exactamente qué cambia en el UPDATE generado. Después provoca un OptimisticLockError desde dos contextos (dos em.fork()) en el mismo test.

17.3 Crea una migración con migration:create tras añadir una propiedad, léela línea a línea y escribe el down si falta. Ejecuta up, down y up y comprueba la tabla mikro_orm_migrations en cada paso.

Nivel 2 · intermedio

17.4 Implementa una transferencia entre cuentas con bloqueo pesimista y orden estable de bloqueo. Escribe un test que lance 50 transferencias cruzadas concurrentes (de A a B y de B a A) y verifique que la suma de los saldos no cambia y que no aparece ningún deadlock.

17.5 Monta una cola de trabajos en la base de datos con PESSIMISTIC_PARTIAL_WRITE. Arranca dos trabajadores en paralelo y demuestra con un test que ningún trabajo se procesa dos veces. Añade reintentos con disponibleEn y un límite de intentos.

17.6 Implementa el borrado lógico completo de Usuario: filtro por defecto, borrado, restauración, papelera, purga por retención e índice único parcial sobre el correo. Escribe el test que reproduce el error de la restricción única y demuestra que tu índice lo resuelve.

17.7 Escribe un suscriptor de auditoría que registre el diff de las entidades que elijas en la misma transacción del cambio, excluyendo campos sensibles. Demuestra con un test que un nativeUpdate no genera registro de auditoría y razona qué harías al respecto.

Nivel 3 · avanzado

17.8 Ejecuta el ciclo completo de expand/contract para dividir nombreCompleto en nombre y apellidos: migración de expansión, código de doble escritura, script de relleno por lotes reanudable, cambio de lectura, migración de contracción con NOT NULL en dos fases. Documenta en qué momento exacto sería seguro revertir el código a la versión anterior.

17.9 Importa 500.000 filas de un CSV manteniendo la memoria por debajo de 300 MB. Compara tres variantes (un solo flush, lotes con flush + clear, y insertMany) midiendo tiempo total y memoria máxima. Explica los resultados.

17.10 Implementa multi-tenancy con columna discriminadora: filtro con argumentos, middleware de resolución, relleno automático del tenantId al crear y un test que intente leer datos de otro inquilino por todas las vías posibles (find, relación cargada, QueryBuilder, SQL crudo) y compruebe qué vías quedan expuestas.

17.11 Añade un endpoint de cobro idempotente con cabecera Idempotency-Key. Lanza la misma petición 20 veces en paralelo y demuestra que se crea un único cobro y que las 20 respuestas son idénticas.

17.12 Instrumenta la aplicación con suscriptores de transacción para medir la duración de cada transacción y el número de consultas por caso de uso. Publica las métricas y añade una alerta para transacciones de más de un segundo.

Solución comentada · 17.4 Transferencia con bloqueo

Las tres decisiones que hacen que este test pase: bloquear en orden estable, tener la comprobación de saldo después del bloqueo y no reutilizar el EM externo.

// src/cuentas/transferencias.service.ts
@Injectable()
export class TransferenciasService {
  constructor(private readonly em: EntityManager) {}
  async transferir(origenId: string, destinoId: string, importe: number): Promise<void> {
    if (importe <= 0) throw new BadRequestException('Importe no positivo');
    if (origenId === destinoId) throw new BadRequestException('Cuentas iguales');
    await conReintentos(() =>
      this.em.transactional(async (em) => {
        // 1. ORDEN ESTABLE: ambas direcciones bloquean en el mismo orden.
        //    Sin esto, A→B y B→A forman un ciclo y el motor mata una.
        const ids = [origenId, destinoId].sort();
        // 2. Bloqueo secuencial, nunca con Promise.all: el orden importa
        //    y dos consultas en paralelo sobre la misma conexión no lo garantizan.
        const cuentas = new Map<string, Cuenta>();
        for (const id of ids) {
          cuentas.set(id, await em.findOneOrFail(Cuenta, id,
            { lockMode: LockMode.PESSIMISTIC_WRITE }));
        }
        const origen = cuentas.get(origenId)!;
        const destino = cuentas.get(destinoId)!;
        // 3. La invariante se comprueba con el dato ya bloqueado:
        //    nadie puede cambiarlo entre la lectura y el UPDATE.
        if (origen.saldo < importe) throw new SaldoInsuficienteError(origenId);
        origen.saldo -= importe;
        destino.saldo += importe;
        em.create(Movimiento, { origen, destino, importe, fecha: new Date() });
      }),
    );
  }
}

Y el test que de verdad demuestra algo: concurrencia real y comprobación de la invariante global.

it('conserva la suma de saldos con 50 transferencias cruzadas', async () => {
  await sembrar({ A: 1_000, B: 1_000 });
  // 25 en cada dirección, todas a la vez: sin orden estable, aquí saltan deadlocks.
  await Promise.all(Array.from({ length: 50 }, (_, i) =>
    i % 2 === 0 ? servicio.transferir('A', 'B', 10) : servicio.transferir('B', 'A', 10),
  ));
  const em = orm.em.fork();                 // contexto limpio: sin Identity Map viejo
  const [a, b] = await Promise.all([
    em.findOneOrFail(Cuenta, 'A'), em.findOneOrFail(Cuenta, 'B'),
  ]);
  expect(a.saldo + b.saldo).toBe(2_000);    // ninguna actualización perdida
  expect(await em.count(Movimiento, {})).toBe(50);
});

Por qué no basta con la transacción. Si quitas lockMode, el test sigue pasando en PostgreSQL con importes fijos por una razón sutil: el UPDATE del ORM escribe el valor absoluto calculado en memoria, y en READ COMMITTED dos transacciones pueden leer el mismo saldo. Con suficiente concurrencia (sube a 500 operaciones) verás cómo la suma deja de cuadrar. Es la actualización perdida en estado puro.

Solución comentada · 17.8 Migración expand/contract

Paso 1 · expansión. Solo añade columnas nullables. Se puede aplicar con la versión antigua del código en marcha.

// src/migrations/Migration20260701090000_expand_nombre.ts
export class Migration20260701090000 extends Migration {
  async up(): Promise<void> {
    this.addSql(`set lock_timeout = '3s';`);
    this.addSql(`alter table "cliente" add column "nombre" varchar(60) null;`);
    this.addSql(`alter table "cliente" add column "apellidos" varchar(120) null;`);
  }
  async down(): Promise<void> {
    this.addSql(`alter table "cliente" drop column "apellidos";`);
    this.addSql(`alter table "cliente" drop column "nombre";`);
  }
}

Paso 2 · doble escritura. El código nuevo escribe en los dos sitios y sigue leyendo el antiguo. Un único punto de escritura evita olvidos.

// src/clientes/cliente.entity.ts (versión de transición)
setNombreCompleto(valor: string): void {
  this.nombreCompleto = valor;                    // formato antiguo (lo que se lee hoy)
  const [nombre, ...resto] = valor.trim().split(/\s+/);
  this.nombre = nombre;                           // formato nuevo (aún no se lee)
  this.apellidos = resto.join(' ') || '-';
}

Paso 3 · relleno reanudable. Fuera de la migración, por lotes, idempotente y con pausa.

// Reanudable: filtra por "nombre is null", así que repetirlo no hace daño.
for (;;) {
  const lote = await em.find(Cliente, { nombre: null }, { limit: 5_000, orderBy: { id: 'asc' } });
  if (!lote.length) break;
  for (const c of lote) c.setNombreCompleto(c.nombreCompleto);
  await em.flush();
  em.clear();
  await new Promise((r) => setTimeout(r, 100));
}

Pasos 4 y 5 · cambio de lectura y fin de la escritura antigua. Dos despliegues separados. Mientras el código siga escribiendo en nombre_completo, revertir es gratis: los datos antiguos están al día. Ese es el último momento seguro para revertir sin pérdida, y responde a la pregunta del enunciado.

Paso 6 · contracción. Días después, con métricas que confirmen que nadie lee la columna antigua.

// src/migrations/Migration20260715090000_contract_nombre.ts
export class Migration20260715090000 extends Migration {
  async up(): Promise<void> {
    this.addSql(`set lock_timeout = '3s';`);
    // NOT NULL en dos fases: validar sin bloquear lecturas y después marcar.
    this.addSql(`alter table "cliente" add constraint "cliente_nombre_nn"
                   check ("nombre" is not null) not valid;`);
    this.addSql(`alter table "cliente" validate constraint "cliente_nombre_nn";`);
    this.addSql(`alter table "cliente" alter column "nombre" set not null;`);
    this.addSql(`alter table "cliente" drop constraint "cliente_nombre_nn";`);
    this.addSql(`alter table "cliente" drop column "nombre_completo";`);
  }
  async down(): Promise<void> {
    // Reversible en estructura, NO en datos: se documenta explícitamente.
    this.addSql(`alter table "cliente" add column "nombre_completo" varchar(180) null;`);
    this.addSql(`update "cliente" set "nombre_completo" = "nombre" || ' ' || "apellidos";`);
    this.addSql(`alter table "cliente" alter column "nombre" drop not null;`);
  }
}
Solución comentada · 17.6 Soft delete completo

La entidad, con el filtro por defecto y sin unique en el correo: la unicidad la aporta un índice parcial creado por la migración.

// src/entities/usuario.entity.ts
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
export class Usuario {
  @PrimaryKey() id: string = randomUUID();
  @Property() email!: string;                 // unicidad solo entre los NO borrados
  @Property() nombre!: string;
  @Property({ nullable: true, index: true }) deletedAt?: Date | null;
  @Property({ nullable: true }) deletedBy?: string | null;
}
// src/migrations/Migration20260710090000_email_unico_activos.ts
async up(): Promise<void> {
  // 1. Detecta duplicados preexistentes ANTES de crear el índice:
  //    si hay dos usuarios activos con el mismo correo, la creación fallará.
  this.addSql(`create unique index "usuario_email_activo_unq"
                 on "usuario" ("email") where "deleted_at" is null;`);
}
async down(): Promise<void> {
  this.addSql(`drop index "usuario_email_activo_unq";`);
}

El repositorio encapsula las consultas que necesitan ver los borrados, para que el resto de la aplicación no toque nunca la opción filters.

// src/usuarios/usuarios.repository.ts
async borrar(id: string, autor: string): Promise<void> {
  await this.em.transactional(async (em) => {
    const u = await em.findOneOrFail(Usuario, id);
    u.deletedAt = new Date();
    u.deletedBy = autor;
    // Cascada LÓGICA: las claves foráneas no se enteran de un borrado lógico.
    await em.nativeUpdate(ApiToken, { usuario: id }, { deletedAt: u.deletedAt });
  });
}
async restaurar(id: string): Promise<void> {
  await this.em.transactional(async (em) => {
    const u = await em.findOneOrFail(Usuario, id, { filters: { softDelete: false } });
    // Antes de restaurar, comprobar que el correo sigue libre: otro usuario
    // pudo registrarse con él mientras este estaba borrado.
    if (await em.count(Usuario, { email: u.email, id: { $ne: id } })) {
      throw new ConflictException('El correo ya está en uso');
    }
    u.deletedAt = null;
    u.deletedBy = null;
  });
}
buscarIncluyendoBorrados(where: FilterQuery<Usuario>): Promise<Usuario[]> {
  return this.em.find(Usuario, where, { filters: { softDelete: false } });
}

El test que reproduce el error clásico y demuestra la corrección:

it('permite volver a registrar un correo cuyo usuario fue borrado', async () => {
  const ana = await repo.crear({ email: 'ana@ejemplo.com', nombre: 'Ana' });
  await repo.borrar(ana.id, 'admin');
  orm.em.clear();
  // Sin el índice parcial: duplicate key value violates unique constraint
  const nueva = await repo.crear({ email: 'ana@ejemplo.com', nombre: 'Ana 2' });
  expect(nueva.id).not.toBe(ana.id);
  // Y la restauración ahora debe fallar de forma controlada, no con un 500.
  await expect(repo.restaurar(ana.id)).rejects.toThrow(ConflictException);
});

Detalle que se olvida siempre: la restauración puede ser imposible. Si liberas el correo al borrar y otra persona lo reutiliza, restaurar crearía un duplicado. Decide la política (rechazar, pedir un correo nuevo, fusionar cuentas) y escríbela en un test, porque de lo contrario aparecerá en producción como un error 500 sin explicación.

17.18 Resumen del capítulo

  • El flush() ya es atómico, pero la unidad atómica la decides tú al elegir dónde ponerlo. Dos flush() no forman una transacción.
  • em.transactional() forkea el EntityManager. Usa el em del parámetro; el externo vive en otro contexto y sobrevive al rollback. Es el error número uno del capítulo.
  • La frontera transaccional va en el caso de uso, corta y sin llamadas externas. Una transacción por petición HTTP es cómoda y peligrosa a la vez: solo con métodos seguros excluidos y disciplina sobre la E/S.
  • Los niveles de aislamiento se eligen por la anomalía que hay que evitar, no por costumbre. PostgreSQL usa READ COMMITTED por defecto; MySQL, REPEATABLE READ. Subir el nivel obliga a reintentar.
  • La actualización perdida es el problema real de la vida diaria. Se resuelve con escritura atómica relativa, bloqueo optimista (versión y 409) o bloqueo pesimista (FOR UPDATE), y cada uno tiene su terreno.
  • SKIP LOCKED convierte una tabla en una cola correcta y transaccional, útil hasta un volumen respetable.
  • Los deadlocks son esperables: orden estable de bloqueo, transacciones cortas y reintento idempotente.
  • updateSchema jamás en producción. Migraciones versionadas, revisadas, con down probado, con lock_timeout y ejecutadas en un paso de despliegue único con bloqueo.
  • Expand/contract es el único patrón que permite desplegar sin cortes: el esquema debe ser compatible con dos versiones del código a la vez, y las contracciones se hacen días después.
  • Las migraciones de datos no son migraciones de esquema: por lotes, idempotentes, reanudables y fuera del pipeline de despliegue cuando son grandes.
  • Los filtros globales son comodidad, no seguridad. Defensa en capas para multi-inquilino y para el borrado lógico.
  • El borrado lógico rompe las restricciones únicas (índice parcial), no dispara cascadas de claves foráneas y no equivale a suprimir datos personales.
  • Los eventos sirven para lo transversal (auditoría, normalización, métricas) y nunca para la lógica de negocio: no se disparan en las operaciones nativas y esconden efectos.
  • El rendimiento del ORM se reduce a cuatro palancas: no cargar lo que no necesitas, escribir por lotes, limpiar el Identity Map en procesos largos y dimensionar el pool con aritmética.
  • Multi-tenancy: columna discriminadora por defecto; esquema o base de datos por inquilino cuando lo exija el aislamiento, sabiendo que el coste operativo pasa a crecer con cada cliente.

17.19 Recursos adicionales

Siguiente paso Con esto cierra la Parte IV. El capítulo 18 junta las tres capas en una aplicación completa: contrato compartido entre Angular y NestJS, entidades y DTOs, y el recorrido de un caso de uso desde el clic hasta el COMMIT.