NestJS in Production · Configuration, Testing, Data and Operations

Transactions Across Repositories

Keep multi-step writes atomic: a transaction helper that services use, so a failure leaves no half-written data.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A transfer service that debits one account and credits another inside one database transaction, using a small Database.transaction() helper provided by a DatabaseModule. When the credit fails, the debit is rolled back. The example uses node:sqlite; the pattern is the same with Prisma ($transaction), TypeORM (DataSource.transaction) or a PostgreSQL client.

The idea

  • Operations that must succeed or fail together (transfer = debit + credit, order = order row + stock update) run in one transaction.
  • Put transaction handling in an infrastructure provider, not in controllers; services call db.transaction(fn).
  • Validate before writing where possible, and let constraint violations roll the transaction back.
  • Keep transactions short: no HTTP calls to other services inside them.

The code

A transfer that is all or nothing
import "reflect-metadata";
import { Injectable, Module } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { DatabaseSync } from "node:sqlite";

@Injectable()
class Database {
  readonly conn = new DatabaseSync(":memory:");
  constructor() {
    this.conn.exec(`CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER NOT NULL CHECK (balance >= 0));
                    INSERT INTO accounts VALUES ('ana', 100), ('ben', 20);`);
  }
  transaction<T>(fn: () => T): T {
    this.conn.exec("BEGIN");
    try { const result = fn(); this.conn.exec("COMMIT"); return result; }
    catch (e) { this.conn.exec("ROLLBACK"); throw e; }
  }
  balances() { return this.conn.prepare("SELECT name, balance FROM accounts ORDER BY name").all(); }
}

@Injectable()
class TransfersService {
  constructor(private db: Database) {}
  transfer(from: string, to: string, amount: number) {
    return this.db.transaction(() => {
      this.db.conn.prepare("UPDATE accounts SET balance = balance - ? WHERE name = ?").run(amount, from);
      const credited = this.db.conn.prepare("UPDATE accounts SET balance = balance + ? WHERE name = ?").run(amount, to);
      if (credited.changes !== 1) throw new Error(`no account ${to}`);
      return "ok";
    });
  }
}

@Module({ providers: [Database, TransfersService] })
class AppModule {}

async function main() {
  const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
  const svc = app.get(TransfersService), db = app.get(Database);
  const attempt = (from: string, to: string, amount: number) => {
    try { svc.transfer(from, to, amount); console.log(`transfer ${amount} ${from}->${to}: ok`); }
    catch (e) { console.log(`transfer ${amount} ${from}->${to}: failed (${(e as Error).message})`); }
    console.log("  balances:", JSON.stringify(db.balances()));
  };
  attempt("ana", "ben", 30);
  attempt("ana", "zoe", 50);
  attempt("ben", "ana", 500);
  await app.close();
}
main();

Output

transfer 30 ana->ben: ok
  balances: [{"name":"ana","balance":70},{"name":"ben","balance":50}]
transfer 50 ana->zoe: failed (no account zoe)
  balances: [{"name":"ana","balance":70},{"name":"ben","balance":50}]
transfer 500 ben->ana: failed (CHECK constraint failed: balance >= 0)
  balances: [{"name":"ana","balance":70},{"name":"ben","balance":50}]

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Two separate writes without a transaction: a crash in between leaves money missing.
  • Calling external APIs (payment providers, email) inside a transaction, holding locks for seconds; use an outbox table and send after commit.
  • Relying only on application checks; database constraints (CHECK, UNIQUE, foreign keys) catch races.

Key points

  • Group writes that belong together in one transaction.
  • Provide transactions from an infrastructure provider; services use it.
  • Short transactions, constraints in the database, side effects after commit.

Further reading: NestJS database techniques · Prisma transactions

Practice questions

What problem does a transaction solve in a transfer?

Answer

The debit and credit either both happen or neither does; a failure between them can't leave the balances inconsistent.

Why shouldn't you call external APIs inside a database transaction?

Answer

They can be slow or hang, holding locks and connections; and their effects can't be rolled back. Record the intent (outbox) and act after commit.

Why keep CHECK and UNIQUE constraints even with application validation?

Answer

Concurrent requests can pass application checks at the same time; constraints in the database enforce rules atomically and abort the transaction.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?