The wall
Opening a job card reached four owners of twenty-seven, and either all of it landed or none of it could. That is a transaction, and a transaction is a handle on a connection. The plain way to give four owners the same handle is to pass it in, and the parameter then spreads until the database sits in the signature of services that never touch it.
That is a bill, and on that codebase I paid it. The handle went in the arguments, written into every signature that takes part in a write, and I would do it again. The case study says why. This library is the other answer to the same wall, built afterwards, on the next project. Neither page will tell you the loser was obvious.
The other answer is to let the handle travel with the request. Node has one mechanism for that: AsyncLocalStorage. The transaction client goes into a store bound to the async scope of the work, and a repository asks for the current one. Whether a store is present is the whole answer to "am I in a transaction". No flag sits beside it to disagree.
The store is written once and never mutated, and run() is the only way in. The library's own rules forbid enterWith, because it leaks the store into whatever the caller does next.
That rule sets a price, and I pay it in the open. Two primitives ship. withTransaction(work) takes a callback, so it can set the store and propagation is implicit. That is the whole value on offer. begin() hands back a block-scoped handle for await using. A block is not a callback, so it sets no store, and inside one you thread scope.tx by hand. The nicer of the two gives up the thing the library exists for. It ships anyway, with the caveat written above the example.
Why the ORM makes it hard
Drizzle rolls back when the callback you gave it throws. That is the only trigger it has. Return normally and it commits.
I had already decided that failures are values and that the library never throws for a condition it models. Those two facts collide exactly once: the value that means "undo this" is a return, and a return is what commits.
I had three ways out. Make callers throw, which hands the contract back. Issue ROLLBACK directly, which means fighting the ORM for control of a connection it owns and bookkeeping it does. Or invert at one boundary and pay for it there.
The inversion
Result goes in, a throw crosses the ORM, a Result comes out. One function turns an err into a RollbackSignal carrying the payload, which is the only place the no-throw contract is broken. The same boundary catches that signal and rebuilds err(payload). Nothing else in the engine throws, and no throw escapes it.
The interesting part is what happens when the rollback itself fails. The domain error is recorded before the signal is thrown. If the connection dies during ROLLBACK, the value that reaches the catch is the driver's error, so the reason for the undo is hidden by the reason the undo failed.
So I carry it out. The caught value is sorted into a structured failure, and the hidden error travels on it as lostDomainError. The caller gets both: why the work was rejected, and why the database could not honour the rejection. A double fault reports as two facts.
Making the compiler hold the line
REQUIRES_NEW runs its work on a second pooled connection while the parent still holds its own. Its outcome has already committed or rolled back independently, so returning it from the outer function would quietly make the outer transaction hang on a result that is no longer live.
The type refuses it. Independent<T, E> is a Result carrying a phantom property keyed by a unique symbol, and the transactional work function returns NonIndependent<T, E>, a Result whose phantom must be absent. return inner fails to compile. settle(inner) strips the brand and is the deliberate opt-in that says an inner failure should take the outer transaction down with it.
Both are types. The brand has no runtime representation, settle is the identity function, and the shipped code is what it would have been without either.
Where I disagreed with myself
There are two implementations of Result in my repos, and they answer the same question in opposite directions.
Here, ok() returns Result<T, never>. The never on the unused side is load-bearing: a work function that only ever returns ok(...) still infers its error channel as never, so E | DrizzleTxError collapses to DrizzleTxError instead of widening to unknown.
In result-kit, written four months earlier, ok() returns the narrow Ok<T> — strictly more precise about the value in hand. Its own comment records what that costs: Ok<T> has no error member, so it offers E no inference site, and a transform over a bare ok(1) had nothing to infer from and fell back to unknown. Result<T, unknown> then assigns into no annotation at all. The fix was to default E to never where it surfaces, which is the same answer I reached one hop later.
Same author, same question, opposite answers, and I wrote down reasons for both at the time. I did not notice the disagreement until I read the two files side by side.
What I'd change
The NestJS decorator resolves its TransactionHost through a process-global Map. A method decorator runs at class-definition time, with no access to the container, so the host registers itself when it is constructed and the decorator looks it up when the method is called. There is no other way to get from a decorator to a managed singleton.
The map is keyed 'default' and the last constructor wins. Two applications in one process and the second silently replaces the first. I disclose it in the limitations and it has a decision record. It is still a mutable global in fifteen hundred lines that are otherwise built to be seam-driven.
The open question is heavier, and I have it in the tracker. Implicit propagation is what this library exists for, and it is separate from how a rollback is signalled — every comparable library throws, and every framework adapter on the roadmap has to bridge from results back to throws. That is no longer fully open, and this page trailed its own record for a month. What I wrote down since is narrower than a flip. Results stay the contract for the core and for anyone writing an adapter, and each app-facing edge is bridged to whatever its host already does — a tRPC procedure throws, a server action returns. Nobody outside the engine pays for the model twice. What has not happened is the shipping. The only adapter that exists is the NestJS one, and it is result-native throughout, so a service author there still writes every check by hand.
Try it
The source and the decision records are in the repo: github.com/alifaroo-q/drizzle-tx. Every claim on this page has a record behind it in docs/adr, including the ones I have since argued with.
One caveat before you reach for it. Installing requires a drizzle-orm release-candidate pin; see the README. Widening it would claim support for a code path nobody has tested.
Sections
- The wall
- Why the ORM makes it hard
- The inversion
- Making the compiler hold the line
- Where I disagreed with myself
- What I'd change
- Try it