Skip to content
Knowledge Hub

Cross-service transactions in NestJS: keeping one connection across services with CLS Advanced

Estimated reading time: 12 min read

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?

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

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=0

1a: 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.

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).

Three singleton services share one client via CLS; when Payment fails, all three roll back
Three singleton services share one client via CLS; when Payment fails, all three roll backStep 1/6: The decorator opens a CLS context; the adapter takes a client, runs BEGIN, and stores the client in the store.time (one checkout)one transaction, one client …pool.connect@Transactionalcls.runBEGINsetTx(client)OrderServicecheckout()InventoryServicereserve()PaymentServicerecord()Connectionclient from pool
Step 1/6

The decorator opens a CLS context; the adapter takes a client, runs BEGIN, and stores the client in the store.

State after each step:

  1. The decorator calls cls.run() (a new store, shallow-copying the parent store); the adapter acquires a client, runs BEGIN, calls setTx(client).
  2. All three services read the same store and therefore the same client, with no extra parameters.
  3. PaymentService throws 23514; the adapter runs ROLLBACK and release(), and the store resets tx to undefined.

Outside a transaction, txHost.tx returns the adapter’s fallback instance (here the Pool): services work both inside and outside a transaction.

@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.

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.

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
  • Required with 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, Nested logs 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.
  • RequiresNew nested 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. Calling this.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.

All scenarios in one run:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-transactions-cls/checkout-demo.ts
Output 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=nested

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

  1. Put @Transactional() on the service’s use-case method, not on the controller or the repository.
  2. Every query in a service reads txHost.tx; forbid injecting Pool directly into business services (lint or review).
  3. Enable the @typescript-eslint/no-floating-promises lint rule to catch calls missing await.
  4. Don’t make HTTP calls, enqueue jobs or use setTimeout inside a @Transactional method; side effects go through a transactional outbox.
  5. Set lock_timeout and idle_in_transaction_session_timeout per Lock timeouts and monitoring.
  6. Recompute the pool’s max when using RequiresNew: one request can hold two connections.
  7. Hand-written adapter: test commit, rollback, a failing ROLLBACK, and Nested.

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
  1. In scenario 1b, why does ROLLBACK remove the order but not restore stock?
  2. InventoryService is a singleton with no per-request fields. How does it get the right client for the checkout in progress?
  3. Why is the audit row in scenario 4 committed even though checkout threw?
  4. Pool max: 5, 5 concurrent requests, each nesting RequiresNew inside Required. What happens?