Skip to content

Build1 publisher3 min readPublished

The optional EntityManager is the bug: moving the transaction boundary into AsyncLocalStorage

A dev.to write-up traces silent partial commits to a parameter nobody notices is missing, and replaces it with async context. The new boundary brings three fresh failure modes.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • In a NestJS application with a repository layer it is possible to run a rollback with no errors and then find a row still in the database that should have disappeared with it.
  • The cause was not a TypeORM or PostgreSQL bug: one of the repositories involved was never inside the transaction, because the EntityManager stopped being passed down three layers up.
  • There was no exception and no warning, and the tests passed because that repository was mocked.
  • The manager is the transaction: if a repository does not use that manager, its queries run on a different connection and end up outside the transaction, silently, with no error or warning, and the rollback does not revert them.
  • TypeORM offers await dataSource.transaction(async (manager) => { ... }), and for a small project this is the correct answer and nothing more is needed.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A dev.to write-up opens with a rollback that ran clean and left a row behind anyway, inside a NestJS application with a repository layer [1]. The cause was not TypeORM and not PostgreSQL: one repository was never inside the transaction, because the EntityManager stopped being passed down three layers up, with no exception and no warning, and the tests passed because that repository was mocked [2][3].

The mechanism is worth stating plainly, because it explains why the failure is silent. In TypeORM the manager is the transaction; a repository that does not use that manager issues its queries on a different connection, so those writes sit outside the transaction and the rollback does not revert them [4]. For a small project, the plain `dataSource.transaction(async (manager) => ...)` form is the correct answer and needs nothing added [5].

Once a repository layer exists, the manager has to travel, and the only way it travels is by hand [6]. The optional parameter then spreads upward into the use case and outward into the port itself: the `UserRepository` interface lives in the domain layer and every method grows a `manager?: EntityManager`, which means the domain now imports from `typeorm` [6][7]. At that point the port has stopped being a port, since it cannot be implemented without dragging the ORM along, including into the in-memory double you would otherwise use to test the application layer [8].

The correctness argument is the sharper one. That question mark carries the correctness of the system and is invisible: one missed argument, in one branch, of one service, is enough to put a write outside the transaction, and it passes code review and passes tests that mock the repository before surfacing in production as something like a user row with no matching settings row [9]. Dropping transactions instead resolves nothing; it postpones the problem to the moment two related writes diverge [10].

The proposed shape moves the boundary to a single point, the controller handling the request, and lets repositories enlist themselves in the transaction in progress without receiving anything as a parameter, in about sixty lines built on AsyncLocalStorage [11]. Node has shipped AsyncLocalStorage since v12: a value set at the root of an asynchronous call chain is readable at any depth, across every await, without being threaded through signatures [12]. The stated goals are a domain port with no TypeORM types, a boundary declared once, repositories that behave identically when no transaction is active, and forgetting to pass something no longer being a possible mistake because nothing is passed [17]. That last item is the real claim: the defect class from earlier is an omitted argument, and an omitted argument cannot exist where no argument does [18].

The second half is the part that matters operationally. Making the boundary implicit makes it wide, and the article names three consequences, each with a fix [16]. A network call inside the transaction holds a pooled connection and its locks for the entire wait [13]. A failure record written in the `catch` is rolled back along with the very failure it was meant to document [14]. And nesting two `execute` calls does not open a nested transaction but two independent ones, with the self-deadlock that permits [15].

Rank those before adopting anything. The catch-block one is the quietest: audit and failure rows written inside a boundary you no longer see in the signature disappear with the rollback, so incident evidence is exactly the data most likely to be lost [14]. The connection-holding one is a capacity problem that only appears under load [13], and the double-boundary deadlock is a self-inflicted stall that will look like a hang, not an error [15].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories