Transactions promise a simple guarantee: either everything commits, or nothing does.
And yet, in a NestJS application with a repository layer, it is perfectly possible to run a rollback with no errors and then find a row still sitting in the database that should have disappeared with it.
This is not a TypeORM or PostgreSQL bug.
One of the repositories involved was never inside the transaction, because the stopped being passed down three layers up.
There was no exception, no warning, and the tests passed because that repository was mocked.
This article describes how to make that class of failure impossible: the transaction opens at a single point — the controller handling the request — and repositories enlist themselves in the transaction in progress, without receiving anything as a parameter.
It comes to about sixty lines built on .
The second part is the one rarely told: three consequences of the transaction boundary, each with its fix.
A network call inside the transaction holds a pooled connection and its locks for the entire wait.
A failure record written in the is rolled back along with the very failure it was meant to document.
And nesting two calls does not open a nested transaction but two independent ones, with the self-deadlock that allows.
The problem: passing the EntityManager by hand TypeORM offers a transaction like this: For a small project this is the correct answer and nothing more is needed.
The problem shows up once a repository layer exists.
The 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 and no warning.
The rollback simply does not revert them.
So the manager has to reach the repository, and it only gets there by being passed by hand.
An application-layer use case ends up like this: The effect on , the interface that declares the port and lives in the domain layer, is the following: This interface lives in the domain layer.
The reason for putting it there is that the domain declares what it needs without knowing how anything is persisted.
And now it imports from .
With that, the port stops being one: it can no longer be implemented without dragging the ORM along, starting with the in-memory double you would use to test the application layer.
The underlying problem is not aesthetic.
That carries the correctness of the system and is invisible: failing to pass through one call, inside one branch, of one service is enough for that write to fall outside the transaction.
It passes code review.
It passes the tests that mock the repository.
And it surfaces in production with symptoms like this one, by way of example: a half-failed operation that leaves a user row without its matching settings row.
The second alternative — doing without transactions — resolves nothing.
It only postpones the problem to the moment two related writes diverge.
The goal, then: The domain port with no TypeORM types.
The transaction boundary declared once, at the entry point.
Repositories that enlist themselves in the active transaction, and behave identically when there is none. "Forgetting to pass something" no longer a possible mistake, because nothing is passed.
AsyncLocalStorage Node has shipped since v12.
It is storage local to the asynchronous call chain: a value is set at the root, and any function below it — at any depth, across every — can read it without receiving it as an argument.
Applied to the problem above, the shape is this: The store is scoped to the asynchronous context, not to a global variable.
Two concurrent HTTP requests each hold their own, with no interference, and outside a the read returns .
That property is what makes the pattern safe.
An fits that description exactly: it is needed throughout the call chain and belongs to none of the intermediate layers.
The solution: TransactionExecutor lives in the project's shared infrastructure.
It is forty-five lines, with no dependencies beyond TypeORM