Cross-service transactions in NestJS: keeping one connection across services with CLS Advanced
Estimated reading time: 12 min read
Read first
The problem
Section titled “The problem”OrderService.checkout() creates an order, calls InventoryService.reserve() to take 2 shirts out of stock (stock 10), then calls PaymentService.record() to record the payment. Each service is a singleton that injects node-postgres’s Pool and calls pool.query itself.
When PaymentService fails, the order is already written and the stock already decremented. Adding BEGIN in OrderService doesn’t save it either: the demo measured stock = 8 after ROLLBACK.
The question: how do three services share one transaction without changing every function signature, and what makes a query silently fall out of the transaction?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| One service, two or three repository functions | Pass client as a parameter |
Repositories accept a PoolClient, like withTransaction |
| Many nested services | CLS + @Transactional() |
Every query reads txHost.tx; tests catch forgotten awaits |
| Providers are already request-scoped for other reasons | A Scope.REQUEST unit of work |
Accept that scope bubbles up the tree (provider scopes); not for cron |
| Some part must commit even if the order rolls back (error logs) | Propagation.RequiresNew |
Enough connections in the pool for two concurrent transactions |
Core concepts
Section titled “Core concepts”A transaction is tied to one connection
Section titled “A transaction is tied to one connection”BEGIN opens a transaction on the session (one connection) that sent it. Statements going through another connection don’t know the transaction exists. pool.query borrows any free connection for each statement (connection pool); this classic mistake is covered in detail in Transactions in code.
Scenario 1b in the demo: OrderService acquires client A and runs BEGIN; InventoryService still uses pool.query.
Loading diagram…
Real output, trimmed (PostgreSQL 17, pool max: 5, Payment fails with 23514 in both):
=== 1a. No transaction, Payment fails === order pid=37168, inventory pid=37168 => orders=1, stock=8, payments=0
=== 1b. BEGIN in OrderService, other services use pool.query === order pid=37169 (inside BEGIN), inventory pid=37170 => orders=0, stock=8, payments=01a: same pid, but each statement autocommits; the same connection does not mean the same transaction. 1b: the order rolls back but the stock does not, which is harder to spot.
Three ways to get the client to every service
Section titled “Three ways to get the client to every service”| Approach | Mechanism | The price |
|---|---|---|
| Parameters | reserve(tx, productId, qty) |
Every function along the path gains a tx parameter, even ones that only forward it |
Scope.REQUEST |
A provider holds the client and is injected into services | Every consumer becomes request-scoped; there is no request in cron or consumers |
| CLS | AsyncLocalStorage holds the client in the async chain’s store | Implicit dependency; transactions leak when code runs outside the chain |
The AsyncLocalStorage article recommends passing business data as parameters. CLS for transactions is a deliberate exception: the boundary still sits at the decorator; only the handle travels implicitly.
How it works
Section titled “How it works”nestjs-cls 7 wraps ALS as ClsService. The @nestjs-cls/transactional 4 plugin adds @Transactional() (wraps a method in a transaction) and TransactionHost.tx (the transaction open in the current store).
The decorator opens a CLS context; the adapter takes a client, runs BEGIN, and stores the client in the store.
State after each step:
- The decorator calls
cls.run()(a new store, shallow-copying the parent store); the adapter acquires a client, runsBEGIN, callssetTx(client). - All three services read the same store and therefore the same client, with no extra parameters.
PaymentServicethrows23514; the adapter runsROLLBACKandrelease(), and the store resetstxtoundefined.
Outside a transaction, txHost.tx returns the adapter’s fallback instance (here the Pool): services work both inside and outside a transaction.
Registering the module
Section titled “Registering the module”@Module({ imports: [ DbModule, // exports the PG_POOL provider ClsModule.forRoot({ global: true, plugins: [ new ClsPluginTransactional({ imports: [DbModule], adapter: new PgTransactionalAdapter(PG_POOL), }), ], }), ],})export class AppModule {}@Injectable()class InventoryService { constructor(private readonly txHost: TransactionHost<PgTransactionalAdapter>) {} reserve(productId: number, qty: number) { return this.txHost.tx.query( 'UPDATE products SET stock = stock - $2 WHERE id = $1', [productId, qty]); }}// OrderService: @Transactional() async checkout(amount: number) { ... }The constructor type must be written directly as TransactionHost<...>, not via a type alias: the runtime metadata becomes Object and Nest reports UnknownDependenciesException.
An adapter for pg: you have to write it
Section titled “An adapter for pg: you have to write it”The official adapter list includes Prisma, Knex, Kysely, pg-promise, TypeORM, MongoDB, Mongoose, Drizzle; there is no package for plain node-postgres (@nestjs-cls/transactional-adapter-pg does not exist on npm). The demo adapter below follows the “Creating a custom adapter” guide and is for demo use only:
export class PgTransactionalAdapter implements TransactionalAdapter<Pool, Queryable, PgTxOptions> { constructor(readonly connectionToken: unknown) {}
optionsFactory = (pool: Pool) => ({ wrapWithTransaction: async (options: PgTxOptions, fn: () => Promise<unknown>, setTx: (tx?: Queryable) => void) => { const client = await pool.connect(); try { await client.query('BEGIN'); setTx(client); const result = await fn(); await client.query('COMMIT'); return result; } catch (err) { await client.query('ROLLBACK').catch(() => {}); throw err; } finally { client.release(); } }, getFallbackInstance: (): Queryable => pool, });}Still missing for production: savepoints for Nested (wrapWithNestedTransaction), timeouts, and handling a broken client when ROLLBACK fails. If you already use Kysely or Drizzle on top of pg, use their official adapters.
Operational details
Section titled “Operational details”The Propagation enum (4.0.1), used as @Transactional(Propagation.RequiresNew):
| Value | A transaction is open | None open |
|---|---|---|
Required (default) |
Reuse it | Open a new one |
RequiresNew |
Open an independent transaction (another connection) | Open a new one |
Nested |
Savepoint if the adapter supports it | Open a new one |
Mandatory |
Reuse it | Throw TransactionNotActiveError |
Supports / NotSupported / Never |
Reuse / temporarily run outside / throw | Run without a transaction |
Requiredwith a transaction already open while still passing options (isolation level): the options are ignored, and the library only logs a warning.- If the adapter has no
wrapWithNestedTransaction,Nestedlogs a warning and opens a new transaction on another connection (from reading the 4.0.1 source, confirmed by demo scenario 7), not a savepoint. RequiresNewnested inside a transaction holds two connections at once; a small pool can be exhausted when many requests do this.@Transactional()replaces the method on the prototype with a Proxy. Callingthis.checkout()from another method still goes through the wrapper, unlike the self-invocation trap of Spring proxies (scenario 3).- It works outside HTTP too: the decorator opens
cls.run()itself, so no middleware is needed just for transactions.
Real-world example
Section titled “Real-world example”All scenarios in one run:
npx --prefix examples ts-node examples/src/nestjs-transactions-cls/checkout-demo.tsOutput of scenarios 2–7, trimmed (pids change per run)
=== 2. @Transactional + CLS, Payment fails === order pid=37169, inventory pid=37169, tx active=true checkout failed: 23514 new row for relation "payments" violates check constraint "payments_amount_check" => orders=0, stock=10, payments=0, audit=-
=== 3. Calling this.checkout() from a method without the decorator === before this.checkout(): tx active=false order pid=37169, inventory pid=37169, tx active=true
=== 4. Forgotten await inside a transaction === audit "forgot-await": isTransactionActive=false => orders=0, stock=10, payments=0, audit=forgot-await
=== 5. setTimeout inside a transaction === setTimeout after 50ms: tx active=false
=== 7. Propagation.Nested with an adapter that does not support it === WARN [TransactionHost] Nested Propagation option is ignored because an adapter does not support nested transactions (for method bound writeNested). => orders=0, stock=10, payments=0, audit=nestedTrade-offs
Section titled “Trade-offs”Pros
-
Services stay singletons, and function signatures don’t change when layers are added.
-
The transaction boundary lives in one place: the decorator on the use case.
-
Named propagation modes cover needs such as independent logging.
< slot="cons"> >
-
Implicit dependency: reading
reserve()doesn’t tell you which transaction it is in. -
Code running outside the async chain (forgotten
await, timers, listeners) silently falls back and autocommits. -
With plain
pg, you have to write and maintain the adapter yourself.
Cons
Production checklist
Section titled “Production checklist”- Put
@Transactional()on the service’s use-case method, not on the controller or the repository. - Every query in a service reads
txHost.tx; forbid injectingPooldirectly into business services (lint or review). - Enable the
@typescript-eslint/no-floating-promiseslint rule to catch calls missingawait. - Don’t make HTTP calls, enqueue jobs or use
setTimeoutinside a@Transactionalmethod; side effects go through a transactional outbox. - Set
lock_timeoutandidle_in_transaction_session_timeoutper Lock timeouts and monitoring. - Recompute the pool’s
maxwhen usingRequiresNew: one request can hold two connections. - Hand-written adapter: test commit, rollback, a failing
ROLLBACK, andNested.
Common mistakes
Section titled “Common mistakes”The most common trap is an un-awaited call inside a transaction:
Forgotten await: the write runs after the transaction has ended
Common
@Transactional()async checkout(amount: number) { this.audit.write('checkout'); // no await await this.payment.record(orderId, amount);}Prefer
@Transactional()async checkout(amount: number) { await this.audit.write('checkout'); await this.payment.record(orderId, amount);}Why
write() has an await before its query. By the time it resumes, the transaction has rolled back and the store has reset tx to undefined, so txHost.tx returns the Pool: the audit row is committed on its own even though checkout failed (scenario 4).
Timers and listeners have the same consequence by a different route:
| Misconception | Consequence | What to do |
|---|---|---|
this.method() bypasses @Transactional as in Spring |
Extra wrappers or needless class splits | The library replaces the method on the prototype; it still works (scenario 3) |
Nested is always a savepoint |
Without adapter support it opens an independent transaction that commits separately | Implement wrapWithNestedTransaction or don’t use Nested |
| A long transaction only costs time | It holds a connection and row locks, other requests wait, the pool runs dry | Transactions contain only SQL; set timeouts in the database |
Self-check
Section titled “Self-check”- In scenario 1b, why does
ROLLBACKremove the order but not restorestock? InventoryServiceis a singleton with no per-request fields. How does it get the right client for the checkout in progress?- Why is the audit row in scenario 4 committed even though
checkoutthrew? - Pool
max: 5, 5 concurrent requests, each nestingRequiresNewinsideRequired. What happens?
Summary
Section titled “Summary”References
Section titled “References”- Transactional plugin — nestjs-cls: adapter list, propagation,
TransactionHost. - Creating a custom adapter — nestjs-cls:
wrapWithTransaction,getFallbackInstance,wrapWithNestedTransaction. - nestjs-cls on GitHub: source of the decorator and
TransactionHost. - Async local storage — NestJS and Injection scopes: ALS instead of request scope, the cost of
Scope.REQUEST. - Transactions — node-postgres: why a transaction must run on a single client.