import { Injectable, Scope } from '@nestjs/common';
import { DataSource, EntityManager, QueryRunner } from 'typeorm';
@Injectable({ scope: Scope.REQUEST })
export class TransactionContext {
private queryRunner?: QueryRunner;
constructor(private readonly dataSource: DataSource) {}
async start(): Promise<void> {
if (this.queryRunner) {
return;
}
this.queryRunner = this.dataSource.createQueryRunner();
await this.queryRunner.connect();
await this.queryRunner.startTransaction('READ COMMITTED');
}
get manager(): EntityManager {
if (!this.queryRunner) {
throw new Error('Transaction has not been started');
}
return this.queryRunner.manager;
}
async commit(): Promise<void> {
if (this.queryRunner && this.queryRunner.isTransactionActive) {
await this.queryRunner.commitTransaction();
}
}
async rollback(): Promise<void> {
if (this.queryRunner && this.queryRunner.isTransactionActive) {
await this.queryRunner.rollbackTransaction();
}
}
async release(): Promise<void> {
if (this.queryRunner && !this.queryRunner.isReleased) {
await this.queryRunner.release();
}
this.queryRunner = undefined;
}
}
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, from } from 'rxjs';
import { catchError, concatMap, finalize } from 'rxjs/operators';
import { TransactionContext } from './transaction.provider';
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private readonly tx: TransactionContext) {}
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
return from(this.tx.start()).pipe(
concatMap(() => next.handle()),
concatMap(async (result) => {
await this.tx.commit();
return result;
}),
catchError((err) => {
return from(this.tx.rollback().then(() => Promise.reject(err)));
}),
finalize(() => {
void this.tx.release();
}),
);
}
}
import { ConflictException, Injectable } from '@nestjs/common';
import { TransactionContext } from './transaction.provider';
import { InventoryItem } from './inventory-item.entity';
import { Order } from './order.entity';
import { LedgerEntry } from './ledger-entry.entity';
interface CreateOrderDto {
sku: string;
quantity: number;
customerId: string;
}
@Injectable()
export class OrdersService {
constructor(private readonly tx: TransactionContext) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const manager = this.tx.manager;
const item = await manager
.createQueryBuilder(InventoryItem, 'item')
.setLock('pessimistic_write')
.where('item.sku = :sku', { sku: dto.sku })
.getOneOrFail();
if (item.available < dto.quantity) {
throw new ConflictException(`Insufficient stock for ${dto.sku}`);
}
item.available -= dto.quantity;
await manager.save(item);
const order = manager.create(Order, {
sku: dto.sku,
quantity: dto.quantity,
customerId: dto.customerId,
total: item.unitPrice * dto.quantity,
});
await manager.save(order);
await manager.save(
manager.create(LedgerEntry, {
orderId: order.id,
amount: order.total,
kind: 'ORDER_PLACED',
}),
);
return order;
}
}
Coordinating several writes that must all succeed or all fail is a recurring problem: create an order, decrement inventory, and record a ledger entry, but only commit if every step works. In TypeORM the primitive for this is a QueryRunner that owns a dedicated connection and an explicit transaction. The challenge in NestJS is threading that single QueryRunner through every service and repository involved in one request without passing it manually as an argument everywhere. This snippet solves that with a request-scoped provider plus an interceptor that opens, commits, and rolls back the transaction around the handler.
In transaction.provider.ts, the TransactionContext provider is declared with Scope.REQUEST, so the DI container creates a fresh instance per HTTP request. It lazily creates a QueryRunner from the injected DataSource, connects it, and starts the transaction on first use via start(). Because it is request-scoped, every service that injects it within the same request receives the exact same instance, and therefore the same connection and transaction — this is what makes ambient transaction propagation work without a global variable or AsyncLocalStorage.
In transaction.interceptor.ts, TransactionInterceptor wires the lifecycle to the request. It resolves the request-scoped TransactionContext, and after the handler stream completes it calls commit(); if the handler throws, the catchError branch calls rollback() before rethrowing. The finally-style release() ensures the connection returns to the pool in every case, preventing leaks that would otherwise exhaust the pool under load.
In orders.service.ts, OrdersService shows the payoff: it obtains a manager from the shared context and performs three writes through it. None of the services need to know about commit or rollback — that concern lives entirely in the interceptor. createOrder reads inventory FOR UPDATE via setLock('pessimistic_write') to avoid oversell under concurrency, then throws a ConflictException when stock is insufficient, which the interceptor turns into a rollback.
The trade-off is that request scope makes the whole injection chain request-scoped and slightly slower to instantiate, and the pattern assumes one logical transaction per request. When those assumptions hold, it yields clean services that are transaction-agnostic yet fully atomic.
Related snips
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
Share this code
Here's the card — post it anywhere.