open atlas
↑ Back to track
NestJS, zero to senior NEST · 04 · 02

Repositories, transactions and the unit of work

A repository is bound to the default manager — calling an injected repo inside dataSource.transaction does NOT join the tx. Route every write through the SAME transactional EntityManager (manager.getRepository), or a partial commit leaves orphaned rows.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The incident channel lights up at 2am: a customer transferred $400 between their own accounts, the source account was debited, and the destination was never credited. The money is gone — not stolen, just vanished into a half-finished write. The transferFunds service is wrapped in dataSource.transaction(async (manager) => { ... }), so the author swore it was atomic. It compiles, it passed code review, it ran fine for months. The bug is invisible in every test because the tests never throw between the two writes. The service injects @InjectRepository(Account) and calls this.accountRepo.save(...) inside the callback — and that repo is bound to the default manager, not the transaction. The debit committed on its own connection; the credit threw; the rollback rolled back nothing the debit had touched. This is the most expensive five-line mistake in the persistence layer.

The repository is your persistence boundary

A repository is the seam between your domain logic and the database. In Nest with TypeORM you usually get one by injecting it — @InjectRepository(Account) hands you a Repository<Account> wired by the TypeOrmModule.forFeature([Account]) registration. That repository carries methods like find, save, and remove, and crucially it is bound to a specific EntityManager: the default manager of the DataSource, which runs each call on its own auto-committed connection.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Account } from './account.entity';

@Injectable()
export class AccountsService {
  constructor(
    @InjectRepository(Account)
    private readonly accountRepo: Repository<Account>,
  ) {}

  // Each call here is its OWN tiny transaction — fine for a single write.
  async getBalance(id: string): Promise<number> {
    const acc = await this.accountRepo.findOneByOrFail({ id });
    return acc.balance;
  }
}

For a single read or a single write, that auto-commit-per-call behaviour is exactly right. The trouble starts the moment one business operation needs two or more writes to succeed or fail together — a transfer, an order with line items, a signup that writes a user and an audit row. That is a transaction, and a transaction is where the repository’s default binding becomes a trap.

Two ways to run a transaction — and they share one manager

TypeORM gives you two transaction APIs. The first is the callback form, dataSource.transaction: it opens a transaction, hands you a transactional EntityManager, commits if the callback resolves, and rolls back if it throws. You never write commit or rollback yourself.

// CALLBACK FORM — auto commit on resolve, auto rollback on throw.
await this.dataSource.transaction(async (manager) => {
  await manager.getRepository(Account).decrement({ id: fromId }, 'balance', amount);
  await manager.getRepository(Account).increment({ id: toId }, 'balance', amount);
  // if either line throws, the WHOLE thing rolls back
});

The second is the manual QueryRunner, when you need explicit control — savepoints, a chosen isolation level, or interleaved logic. You own the lifecycle, and release() MUST live in a finally or you leak a pooled connection:

// MANUAL FORM — you own commit/rollback/release.
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE'); // optional isolation level
try {
  const repo = queryRunner.manager.getRepository(Account);
  await repo.decrement({ id: fromId }, 'balance', amount);
  await repo.increment({ id: toId }, 'balance', amount);
  await queryRunner.commitTransaction();
} catch (err) {
  await queryRunner.rollbackTransaction();
  throw err;
} finally {
  await queryRunner.release(); // ALWAYS — even on success
}

Notice what both forms have in common: every write goes through a repository obtained from the transaction’s own managermanager.getRepository(...) in the callback, queryRunner.manager.getRepository(...) in the manual form. That shared manager is the unit of work.

The unit of work, and the trap that breaks it

If you only remember one sentence from this lesson, it’s this: wrapping code in a transaction callback is not the same as putting writes inside that transaction. The unit of work is the idea that one atomic business operation maps to exactly one transaction, and all the writes inside it go through the same transactional EntityManager. Multiple repositories, one manager. Break that rule and atomicity silently disappears.

Here is the trap, the transferFunds from the incident — done WRONG:

// WRONG — injected repo ignores the transaction's manager.
async transferFunds(fromId: string, toId: string, amount: number) {
  await this.dataSource.transaction(async (manager) => {
    // this.accountRepo is bound to the DEFAULT manager, NOT `manager`.
    await this.accountRepo.decrement({ id: fromId }, 'balance', amount); // auto-commits!
    if (amount > 1000) throw new Error('needs approval');                // too late
    await this.accountRepo.increment({ id: toId }, 'balance', amount);
  });
}

The decrement runs on the injected repo’s own connection and commits immediately. When the next line throws, dataSource.transaction rolls back its transaction — which never contained the debit. The source account is debited, the destination is untouched, and the rollback was a no-op over an empty transaction. Now the same operation done RIGHT:

// RIGHT — every write goes through the transaction's own manager.
async transferFunds(fromId: string, toId: string, amount: number) {
  await this.dataSource.transaction(async (manager) => {
    const accounts = manager.getRepository(Account);            // bound to THIS tx
    await accounts.decrement({ id: fromId }, 'balance', amount);
    if (amount > 1000) throw new Error('needs approval');       // now rolls back the debit
    await accounts.increment({ id: toId }, 'balance', amount);
  }); // commits only if BOTH writes succeed
}

If you already hold an injected repository and want to reuse it transactionally, manager.withRepository(this.accountRepo) re-binds that exact repository (custom methods included) to the transactional manager. The alternative, for teams who hate threading manager through every method, is a transactional-context library — typeorm-transactional, built on AsyncLocalStorage — where a @Transactional() decorator stores the active manager in async context and injected repositories transparently pick it up. That keeps service code clean at the cost of one more dependency and a patched DataSource.

ApproachWho owns commit/rollbackAtomic across multiple writes?Isolation level controlMain risk
Injected repo (no tx)Each call auto-commitsNo — every write is its own txNoneSilent partial commits
dataSource.transaction + manager.getRepositoryTypeORM (auto on resolve/throw)Yes — shared managerOptional first argForgetting to use the manager
Manual QueryRunnerYou — commit/rollback/releaseYes — queryRunner.managerFull (startTransaction, savepoints)Leaking a connection (no release)
@Transactional() context libDecorator (AsyncLocalStorage)Yes — injected repos auto-joinDecorator optionExtra dep + patched DataSource

Isolation levels, retries, and migrations

The default isolation level on Postgres and MySQL is READ COMMITTED — each statement sees rows committed before it ran, which is fine for most writes but allows non-repeatable reads. When an operation must enforce an invariant across rows it reads then writes (no overdraft, no double-booking), raise it to SERIALIZABLE (or use explicit row locks). Stricter isolation trades throughput and can abort transactions, so a SERIALIZABLE transaction must be retryable: on a serialization failure (Postgres 40001) or a deadlock (40P01), catch, back off, and replay the whole unit of work.

async transferWithRetry(fromId: string, toId: string, amount: number) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await this.dataSource.transaction('SERIALIZABLE', async (manager) => {
        const accounts = manager.getRepository(Account);
        await accounts.decrement({ id: fromId }, 'balance', amount);
        await accounts.increment({ id: toId }, 'balance', amount);
      });
    } catch (err: any) {
      if (err.code === '40001' || err.code === '40P01') continue; // serialization / deadlock -> retry
      throw err;
    }
  }
  throw new Error('transfer failed after retries');
}

One last discipline: the schema those repositories write to belongs in migrations under version control, generated with typeorm migration:generate and run on deploy. Never ship with synchronize: true — it diffs entities against the live schema at boot and can silently drop columns. Migrations are reviewable, ordered, and reversible; runtime sync is a production outage waiting for the wrong startup.

Why this works

Why is the partial-commit trap invisible until production? Because the bug only manifests when something throws between the two writes — and your tests almost never make that happen. The happy-path test transfers a valid amount, both writes “succeed” (each auto-commits on its own connection), the balances look right, green check. Only a real-world failure mid-operation — a constraint violation, a timeout, an approval rule — splits the two writes apart and reveals that they were never in the same transaction. The code reads as atomic, the type checker is happy, and the test suite is silent. That is why “it’s wrapped in a transaction” is not proof of atomicity; “every write goes through the same manager” is.

Pick the best fit

You must debit one account and credit another so the pair is atomic — both succeed or neither does. How do you run the two writes?

Quiz

Inside dataSource.transaction(async (manager) => { ... }), you call an injected @InjectRepository(User) repo's save(), and the next line throws. What happens?

Quiz

You run a multi-step write with a manual QueryRunner and a SERIALIZABLE transaction. Which two things must you get right?

Recall before you leave
  1. 01
    Explain the partial-commit trap: why does calling an injected @InjectRepository repo inside dataSource.transaction fail to be atomic, and how do you fix it?
  2. 02
    Contrast the callback and QueryRunner transaction forms, and say when you'd raise isolation and how to handle the fallout.
Recap

A repository is the persistence boundary between your domain and the database, and an injected @InjectRepository repo is bound to the DataSource’s default EntityManager — each call auto-commits on its own connection, which is correct for a single read or write. A unit of work is one atomic business operation mapped to exactly one transaction, where ALL writes go through the SAME transactional manager. TypeORM offers two transaction forms: the callback dataSource.transaction(async (manager) => …) which auto-commits on resolve and auto-rolls-back on throw, and the manual QueryRunner (connect, startTransaction, commit/rollbackTransaction, release in a finally) for explicit control and isolation. The senior trap: calling an injected repo inside dataSource.transaction does NOT join that transaction — the write auto-commits on the default manager, so a mid-operation throw leaves a partial commit (debited but not credited) that is invisible until production because tests rarely throw between writes. The fixes are to route writes through manager.getRepository / manager.withRepository inside the callback, or to use a @Transactional() context library built on AsyncLocalStorage. Raise isolation to SERIALIZABLE to enforce invariants, retry on serialization failures and deadlocks, and keep the schema in version-controlled migrations rather than runtime synchronize. Now when you read a service and spot an injected @InjectRepository used inside a dataSource.transaction callback, you’ll know exactly what that code silently risks and how to fix it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.