Transacciones atómicas en MikroORM con NestJS: el bug que no lanza ningún error

Imagen generada por IA para el blog de Orbitant sobre transacciones atómicas en MikroORM con NestJS, que muestra dos secciones de un tubo de metal separadas, una de ellas con un anillo de luz cian brillante en el extremo.

Todo parecía funcionar correctamente. Los tests pasaban sin problemas, la aplicación no lanzaba ninguna excepción y, sin embargo, de vez en cuando aparecía en la base de datos un registro huérfano: una entidad se había guardado sin la entidad relacionada que, por diseño, siempre debía acompañarla.

WILBER CONCEPCIÓN
AI Engineer

Trabajo para el proyecto de gestión documental en uno de los clientes de Orbitant y hace unas semanas me encontré con uno de esos bugs silenciosos que no dan la cara hasta que revisas la base de datos línea a línea. El problema tenía que ver con cómo conseguir transacciones atómicas en MikroORM con NestJS cuando dos operaciones de persistencia dependen la una de la otra: si fallaba la segunda, la primera se quedaba guardada igualmente, sin que nada en los logs lo delatara.

Lo cuento tal y como lo presenté en una de nuestras sesiones internas, con el código real del servicio y las preguntas que surgieron en la sala, porque el patrón que lo causó es fácil de reproducir sin darte cuenta.

El contexto: dos entidades que deben guardarse juntas o no guardarse

El servicio en cuestión es un microservicio de NestJS que recibe eventos de venta a través de una cola de mensajería (AMQP) y usa MikroORM como ORM contra PostgreSQL. Cuando llega un evento, tiene que persistir dos entidades relacionadas:

  1. Entidad A, la referencia de origen del evento.
  2. Entidad B, la entidad principal del dominio.

La regla es simple de enunciar y fácil de romper sin querer: o se guardan las dos o no se guarda ninguna. Este tipo de garantía, que en cualquier base de datos relacional se apoya en el concepto de transacción atómica de toda la vida, es justo lo que necesitábamos entre estas dos escrituras. Si la entidad B falla por el motivo que sea (una validación, una restricción de la base de datos, un dato inesperado en el payload), la entidad A no puede quedarse persistida por su cuenta.

Para eso existe el decorador @Transactional() de MikroORM: abre una transacción sobre el EntityManager del contexto (this.em) y hace rollback automático si algo dentro del método lanza una excepción. Sobre el papel, era justo lo que necesitábamos.

Este tipo de fallo pertenece a la misma familia de problemas que cómo gestionamos los errores cuando algo falla a mitad de un flujo asíncrono en un service bus: la pregunta no es solo qué hacer cuando algo falla, sino qué pasa con lo que ya se había persistido antes de que fallara.

Por qué las transacciones atómicas en MikroORM con NestJS dejaban de serlo

El problema apareció por una línea que, vista de forma aislada, no parece peligrosa: dentro de un método anotado con @Transactional(), llamábamos a this.em.fork().

@Transactional() // abre una TX sobre this.em
async processEvent(...) {
  const em = this.em.fork();
  // EM nuevo e independiente → FUERA de la TX

  const entityA = await this.upsertEntityA(
    payload, { em } // se autocommitea
  );
  const entityB = await this.upsertEntityB(
    entityA, { em } // si falla → entityA queda huérfana
  );
}

La invocación em.fork() crea un EntityManager completamente nuevo e independiente del que usa el decorador. Ese fork no participa en la transacción abierta por @Transactional(), así que cada operación que pasa por él se autocommitea por separado. Si upsertEntityB fallaba, upsertEntityA ya se había guardado en firme, y el rollback del decorador no podía hacer nada porque actuaba sobre un EntityManager distinto al que había hecho el commit real.

El resultado eran registros huérfanos en la base de datos, sin ninguna excepción visible, y con los tests unitarios en verde porque ninguno comprobaba la atomicidad entre ambas operaciones. Lo detecté revisando directamente las tablas: una tenía más registros que la otra, cuando deberían coincidir siempre.

Fue precisamente lo que me preguntó mi compañero Kevin Martínez durante la sesión: cómo nos habíamos dado cuenta de que el rollback no estaba funcionando de verdad. La pista llegó comparando el número de registros entre ambas tablas: cuando dejan de coincidir, algo se está quedando a medias.

Diagrama de secuencia que ilustra el error de transacción en MikroORM con NestJS, mostrando cómo el uso de em.fork genera un registro huérfano al fallar la Entidad B.

La solución: em.transactional() con un Entity Manager compartido

La solución fue eliminar el decorador y usar en su lugar em.transactional(), la forma explícita que describe la propia documentación de MikroORM sobre transacciones para abrir una transacción. Este método recibe un callback con un txEm, un EntityManager transaccional, y ese mismo txEm es el que se pasa a todas las operaciones que deben compartir la transacción.

// Sin @Transactional() — control explícito
async processEvent(...) {
  // ...validaciones previas...

  await this.em.transactional(async (txEm) => {
    const entityA = await this.upsertEntityA(
      payload, { em: txEm }
    );
    const entityB = await this.upsertEntityB(
      entityA, { em: txEm }
    );
    await this.publishEvent(entityB);
  });
  // Si CUALQUIERA falla → ROLLBACK completo
}

Con este cambio, si cualquiera de las dos operaciones falla, se hace rollback de todo y no quedan registros huérfanos ni inconsistencias entre tablas.

Otro compañero, Daniel Alarcón, añadió un matiz importante durante la discusión: el problema de encadenar métodos anotados que llaman a otros métodos anotados es que se pierde la trazabilidad de la transacción, ya que no resulta evidente, leyendo el código, qué EntityManager está usando cada operación en cada momento. Con em.transactional() generas tu propia instancia del EntityManager y controlas explícitamente qué entra y qué no entra en la transacción. El código es más extenso, pero es imposible perder de vista qué se está compartiendo.

También noté algo al investigar el bug, que tiene que ver con cómo MikroORM gestiona el identity map y el contexto de cada petición: cuando usas el decorador, el EntityManager del contexto se comparte hacia abajo en toda la cadena de llamadas. Si ese contexto ya viene sucio desde arriba, arrastra ese estado a operaciones que no deberían depender de él. Con em.transactional(), en cambio, el contexto que recibe el callback siempre parte limpio.

Diagrama de secuencia que muestra la solución a la transacción en MikroORM con NestJS mediante em.transactional, garantizando el rollback completo si falla la Entidad B.

Lo que nos enseñó discutirlo en la sesión

La conversación durante la sesión sacó a la luz matices que no habría visto solo. Otro de mis compañeros software engineers, Alfonso Doménech, contó que se había topado con una variante del mismo problema, pero por el lado de la memoria: un servicio que acababa petando porque no se limpiaban las referencias que MikroORM iba acumulando en el EntityManager antes de persistirlas. La causa de fondo era la misma que la nuestra: gestionar mal el ciclo de vida del EntityManager, aunque el síntoma fuera distinto.

Por último, mi compañero David Rueda planteó la pregunta que más me hizo pensar: si con em.transactional() tienes que pasar el txEm a mano entre métodos, ¿qué pasa con la reutilización de código cuando una transacción es más larga o cuando varias operaciones comparten lógica? Su planteamiento es acertado: los decoradores dan reutilización gratis a cambio de perder el control fino sobre la transacción; em.transactional() da control fino a cambio de repetir esa cadena de paso de parámetros en cada operación crítica. No hay una respuesta única, depende de si la operación es lo bastante crítica como para justificar ese coste.

También quedó claro en la sala que NestJS distingue entre los decoradores para controladores HTTP y los que se usan para consumidores de colas de mensajes, y que ese es otro punto donde te atas a una forma concreta de manejar el contexto en cuanto decides cuál usar, muy en la línea de las prácticas que solemos seguir a la hora de tocar un microservicio en producción: cualquier decisión de este tipo se paga después, para bien o para mal.

Las dos reglas que me llevo de esta sesión son estas:

  • Nunca combines @Transactional() con em.fork(): se anulan mutuamente, porque el decorador opera sobre un EntityManager y el fork crea uno distinto.
  • Para operaciones críticas donde necesitas atomicidad real, usa em.transactional() con un txEm compartido explícitamente entre todas las operaciones implicadas.

El próximo paso es escribir tests que fuercen el fallo de la segunda operación y comprueben que la primera también se deshace. Es la mejor forma de que este mismo patrón no vuelva a colarse en otro servicio que use em.fork() sin pensarlo dos veces.

Preguntas frecuentes

¿Por qué @Transactional() y em.fork() no lanzan ningún error si fallan?
Porque técnicamente no hay ningún error de MikroORM ni de PostgreSQL: cada operación se ejecuta y se commitea correctamente por separado. El problema no es una excepción, es que dos operaciones que deberían compartir una transacción no la están compartiendo.

¿Cuándo conviene usar em.transactional() en lugar del decorador @Transactional()?
Cuando la atomicidad entre varias operaciones es un requisito real del negocio, sobre todo si dentro del método hay alguna llamada que crea o podría crear un EntityManager distinto, como em.fork().

¿Qué diferencia hay entre un test unitario normal y un test de atomicidad?
Un test unitario normal comprueba que cada operación funciona por separado. Un test de atomicidad fuerza el fallo de una de las operaciones y comprueba que ninguna de las escrituras relacionadas queda persistida.

Sigue nuestro viaje construyendo agentes IA desde dentro