MikroORM transactions in NestJS: the bug that throws no error

AI-generated image for Orbitant's blog about MikroORM transactions in NestJS, featuring two separated metal pipe sections, with one end displaying a glowing cyan ring light.

Everything looked fine. The tests passed without a hitch, the application threw no exceptions, and yet every now and then an orphan record showed up in the database: an entity saved without the related entity that, by design, was always supposed to come with it.

WILBER CONCEPCIÓN
AI Engineer

I work on the document management project at one of Orbitant’s clients, and a few weeks ago I ran into one of those silent bugs that doesn’t show its face until you go through the database row by row. The problem was about how to get atomic MikroORM transactions in NestJS when two persistence operations depend on each other: if the second one failed, the first one stayed saved anyway, with nothing in the logs to give it away.

I’m telling it the way I presented it in one of our internal sessions, with the real service code and the questions that came up in the room, because the pattern behind it is easy to reproduce without noticing.

The context: two entities that must be saved together or not at all

The service in question is a NestJS microservice that receives sale events through a message queue (AMQP) and uses MikroORM against PostgreSQL. When an event arrives, it has to persist two related entities:

  1. Entity A, the event’s source reference.
  2. Entity B, the main domain entity.

The rule is simple to state and easy to break by accident: either both get saved, or neither does. That kind of guarantee — the one any relational database backs with the good old atomic transaction — is exactly what we needed between these two writes. If entity B fails for whatever reason (a validation, a database constraint, an unexpected value in the payload), entity A can’t be left persisted on its own.

That’s what MikroORM’s @Transactional() decorator is for: it opens a transaction on the context’s EntityManager (this.em) and rolls back automatically if anything inside the method throws. On paper, it was exactly what we needed.

This kind of failure belongs to the same family of problems as how we handle errors when something breaks halfway through an async flow in a service bus: the question isn’t only what to do when something fails, but what happens to whatever was already persisted before it failed.

Why MikroORM transactions in NestJS stopped being atomic

The problem came from a single line that, seen in isolation, doesn’t look dangerous: inside a method annotated with @Transactional(), we were calling this.em.fork().

@Transactional() // opens a TX on this.em
async processEvent(...) {
  const em = this.em.fork();
  // brand-new, independent EM → OUTSIDE the TX

  const entityA = await this.upsertEntityA(
    payload, { em } // autocommits
  );
  const entityB = await this.upsertEntityB(
    entityA, { em } // if it fails → entityA is orphaned
  );
}

Calling em.fork() creates a completely new EntityManager, independent from the one the decorator uses. That fork doesn’t take part in the transaction opened by @Transactional(), so every operation going through it autocommits separately. If upsertEntityB failed, upsertEntityA had already been committed for good, and the decorator’s rollback couldn’t do a thing, because it was acting on a different EntityManager from the one that had done the real commit.

The result was orphan records in the database, with no visible exception, and with unit tests all green because none of them checked atomicity between the two operations. I caught it by looking at the tables directly: one had more rows than the other, when they should always match.

That’s exactly what my colleague Kevin Martínez asked me during the session: how did we realise the rollback wasn’t actually working? The clue came from comparing the row count between both tables — when they stop matching, something is being left half-done.

Sequence diagram showing a transaction error in MikroORM with NestJS, where an independent Entity Manager fork causes an orphaned record when Entity B fails.

The fix: em.transactional() with a shared Entity Manager

The fix was to drop the decorator and use em.transactional() instead, the explicit way to open a transaction described in MikroORM’s own transaction documentation. This method takes a callback with a txEm, a transactional EntityManager, and that same txEm is what gets passed to every operation that has to share the transaction.

// No @Transactional() — explicit control
async processEvent(...) {
  // ...preliminary validations...

  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);
  });
  // If ANY of them fails → full ROLLBACK
}

With this change, if either of the two operations fails, everything rolls back, and no orphan records or cross-table inconsistencies are left behind.

Another colleague, Daniel Alarcón, added an important nuance during the discussion: the problem with chaining annotated methods that call other annotated methods is that you lose the traceability of the transaction, because it isn’t obvious from reading the code which EntityManager each operation is using at any given point. With em.transactional() you create your own EntityManager instance and explicitly control what goes into the transaction and what doesn’t. The code is longer, but it’s impossible to lose track of what’s being shared.

I also noticed something while digging into the bug, related to how MikroORM handles the identity map and the per-request context: when you use the decorator, the context’s EntityManager is shared downwards through the whole call chain. If that context already comes in dirty from above, it drags that state into operations that shouldn’t depend on it. With em.transactional(), on the other hand, the context the callback receives always starts clean.

Sequence diagram showing the correct transaction solution in MikroORM with NestJS using a shared txEm, ensuring a full rollback when Entity B fails.

What discussing it in the session taught us

The conversation during the session surfaced nuances I wouldn’t have seen on my own. Another of my fellow software engineers, Alfonso Doménech, told us he’d hit a variant of the same problem, but from the memory side: a service that ended up blowing up because the references MikroORM kept piling up in the EntityManager weren’t being cleared before persisting them. The underlying cause was the same as ours — mismanaging the EntityManager lifecycle — even if the symptom looked different.

Finally, my colleague David Rueda raised the question that made me think the most: if with em.transactional() you have to pass the txEm by hand between methods, what happens to code reuse when a transaction is longer, or when several operations share logic? His point is a fair one: decorators give you reuse for free in exchange for losing fine-grained control over the transaction; em.transactional() gives you fine-grained control in exchange for repeating that parameter-passing chain in every critical operation. There’s no single answer — it depends on whether the operation is critical enough to justify the cost.

It also became clear in the room that NestJS distinguishes between decorators for HTTP controllers and those used for message queue consumers, and that this is another point where you tie yourself to a specific way of handling context the moment you pick one, very much in line with the practices we tend to follow when touching a microservice in production: any decision of this kind gets paid for later, for better or worse.

The two rules I’m taking away from this session are these:

  • Never combine @Transactional() with em.fork(): they cancel each other out, because the decorator operates on one EntityManager and the fork creates a different one.
  • For critical operations where you need real atomicity, use em.transactional() with a txEm shared explicitly across every operation involved.

The next step is writing tests that force the second operation to fail and check that the first one is undone too. It’s the best way to keep this same pattern from sneaking into another service that uses em.fork() without thinking twice.

Frequently asked questions

Why don’t @Transactional() and em.fork() throw any error when they fail?
Because technically there is no MikroORM or PostgreSQL error: each operation runs and commits correctly on its own. The problem isn’t an exception — it’s that two operations that should be sharing a transaction aren’t sharing it.

When should you use em.transactional() instead of the @Transactional() decorator?
When atomicity across several operations is a real business requirement, especially if there’s a call inside the method that creates — or could create — a different EntityManager, such as em.fork().

What’s the difference between a regular unit test and an atomicity test?
A regular unit test checks that each operation works on its own. An atomicity test forces one of the operations to fail and checks that none of the related writes are left persisted.

Follow our journey building AI agents from the inside