Dev iconDevSep 10, 2026 ~8 min source read

Generalizing Transactions in NestJS: a UnitOfWork port that works across TypeORM and MongoDB

When a use case must wait on an external service before writing, holding a DB transaction across that wait wastes connections and locks. Extracting a minimal UnitOfWork interface into the domain lets use cases control transaction scope while keeping the application layer independent of a particular ORM.

Generalizing Transactions in NestJS: A Domain Port over TypeORM and MongoDB

Share this story

Send the public story page.

Useful takeaways from this story.

Declare a minimal UnitOfWork interface in the domain so use cases can decide what to run inside a unit without depending on TypeORM or MongoDB implementations.

Implement the same UnitOfWork contract for different databases and run the same use-case tests against both to verify the abstraction actually generalizes.

# The problem: waiting on third parties inside a transaction

An HTTP handler often wraps a whole request in a transaction via an injected executor. That pattern works when the request's work talks only to the database. It breaks down when the use case must call an external service (payment gateway, file store, long-running generator) before it writes.

If you open a transaction and then await an external call, the connection and any row locks remain held for the duration of that external wait. The result is lower throughput: pooled connections busy waiting, locked rows blocking other requests.

# Why moving the scope matters

The simple fix is: put only database operations inside the transaction. But the controller that opens the original executor cannot make that decision because it doesn't know which part of the use case is the write and which part is the wait. That knowledge lives in the use case.

If the use case directly depends on the concrete transaction executor (for example, a TypeOrmTransactionExecutor), the application layer becomes coupled to the ORM. That coupling makes the use case harder to test and violates the goal of keeping domain code persistence-agnostic.

# The solution: a UnitOfWork domain port

Declare a minimal UnitOfWork interface in the domain. The contract in the article is short and focused:

  • UnitOfWork exposes run (props: UnitOfWorkProps): Promise and should join an existing unit in the current async context when present.

This interface gives use cases a collaborator that can run a block as a single unit without tying them to TypeORM, MongoDB, or any implementation detail.

# How this changes use-case code

Instead of the controller wrapping a whole handler, the use case receives a UnitOfWork and invokes unit.run around the database-writing portion. The external call (the wait) happens outside the unit. That pattern keeps transactions short and scoped to DB operations while letting the use case decide where to start and stop the unit.

# Verifying the abstraction: two implementations

To know whether the UnitOfWork generalizes, implement the interface for multiple engines and run the same use-case tests against each. The article shows implementations over PostgreSQL (TypeORM) and MongoDB. Running the same tests on both is the only reliable check that the contract is useful rather than just renaming the same executor.

Tests also reveal differences between engines that the interface cannot hide. Those differences matter for correctness and performance and should inform whether the abstraction is safe to trust in a given context.

# Practical takeaways

  • Keep transactions short: never await slow external services while holding a DB transaction.
  • Push transaction-scope decisions down into the use case, but avoid injecting concrete ORM executors there.
  • Declare a tiny UnitOfWork interface in the domain and implement it per persistence engine.
  • Validate the abstraction by running the same tests against each implementation and inspect mismatches.

The pattern aligns use-case responsibility with transaction scope, restores testability, and surfaces engine differences through concrete tests rather than hiding them behind a single executor implementation.

More context around this story.

Loading more related stories...

Keep reading in the app

Open the app view to save this story, compare related coverage, and continue from the same source.

Open in app