open atlas
↑ К треку
NestJS с нуля до senior NEST · 04 · 02

Репозитории, транзакции и unit of work

Repository привязан к дефолтному manager — вызов инжектированного repo внутри dataSource.transaction НЕ входит в транзакцию. Гони каждую запись через ОДИН транзакционный EntityManager (manager.getRepository), иначе частичный коммит оставит осиротевшие строки.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Канал инцидентов вспыхивает в два часа ночи: клиент перевёл $400 между своими счетами, счёт-источник списан, а счёт-получатель так и не пополнен. Деньги пропали — не украдены, просто растворились в недописанной записи. Сервис transferFunds обёрнут в dataSource.transaction(async (manager) => { ... }), так что автор клялся, что это атомарно. Оно компилируется, прошло ревью, месяцами работало без проблем. Баг невидим в каждом тесте, потому что тесты никогда не выбрасывают между двумя записями. Сервис инжектит @InjectRepository(Account) и вызывает this.accountRepo.save(...) внутри колбэка — а этот repo привязан к дефолтному manager, а не к транзакции. Списание закоммитилось на своём соединении; пополнение выбросило; rollback откатил ничего из того, что трогало списание. Это самая дорогая ошибка из пяти строк во всём слое персистентности.

Repository — это твоя граница персистентности

Repository — это шов между доменной логикой и базой. В Nest с TypeORM ты обычно получаешь его инжекцией — @InjectRepository(Account) отдаёт тебе Repository<Account>, провязанный регистрацией TypeOrmModule.forFeature([Account]). Этот repository несёт методы вроде find, save и remove, и, что критично, он привязан к конкретному EntityManager: к дефолтному manager у DataSource, который выполняет каждый вызов на своём авто-коммитящем соединении.

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>,
  ) {}

  // Каждый вызов здесь — СВОЯ крошечная транзакция — норм для одной записи.
  async getBalance(id: string): Promise<number> {
    const acc = await this.accountRepo.findOneByOrFail({ id });
    return acc.balance;
  }
}

Для одного чтения или одной записи это поведение «авто-коммит на вызов» — ровно то, что нужно. Беда начинается в тот момент, когда одной бизнес-операции нужно, чтобы две или больше записи прошли или упали вместе — перевод, заказ с позициями, регистрация, которая пишет пользователя и строку аудита. Это транзакция, и именно в транзакции дефолтная привязка repository превращается в ловушку.

Два способа выполнить транзакцию — и оба разделяют один manager

TypeORM даёт два API транзакций. Первый — форма-колбэк, dataSource.transaction: она открывает транзакцию, отдаёт тебе транзакционный EntityManager, коммитит, если колбэк зарезолвился, и откатывает, если он выбросил. Ты никогда не пишешь commit или rollback руками.

// ФОРМА-КОЛБЭК — авто-commit при resolve, авто-rollback при 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);
  // если любая строка выбросит, откатится ВСЁ
});

Второй — ручной QueryRunner, когда нужен явный контроль — savepoint’ы, выбранный уровень изоляции или переплетённая логика. Жизненным циклом владеешь ты, и release() ОБЯЗАН жить в finally, иначе ты утечёшь соединение из пула:

// РУЧНАЯ ФОРМА — commit/rollback/release на тебе.
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE'); // опциональный уровень изоляции
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(); // ВСЕГДА — даже при успехе
}

Заметь, что общего у обеих форм: каждая запись идёт через repository, полученный из собственного manager транзакцииmanager.getRepository(...) в колбэке, queryRunner.manager.getRepository(...) в ручной форме. Этот общий manager и есть unit of work.

Unit of work (единица работы) и ловушка, которая его ломает

Если запомнишь из этого урока одну фразу, пусть это будет: обернуть код в колбэк транзакции — не то же самое, что поместить записи внутрь этой транзакции. Unit of work — единица работы — это идея, что одна атомарная бизнес-операция отображается ровно в одну транзакцию, и все записи внутри неё идут через один и тот же транзакционный EntityManager. Несколько репозиториев, один manager. Нарушь это правило — и атомарность тихо исчезнет.

Вот ловушка, тот самый transferFunds из инцидента — сделанный НЕВЕРНО:

// НЕВЕРНО — инжектированный repo игнорирует manager транзакции.
async transferFunds(fromId: string, toId: string, amount: number) {
  await this.dataSource.transaction(async (manager) => {
    // this.accountRepo привязан к ДЕФОЛТНОМУ manager, НЕ к `manager`.
    await this.accountRepo.decrement({ id: fromId }, 'balance', amount); // авто-коммит!
    if (amount > 1000) throw new Error('needs approval');                // слишком поздно
    await this.accountRepo.increment({ id: toId }, 'balance', amount);
  });
}

decrement выполняется на собственном соединении инжектированного repo и коммитится немедленно. Когда следующая строка выбрасывает, dataSource.transaction откатывает свою транзакцию — которая никогда не содержала списание. Счёт-источник списан, получатель нетронут, а rollback был no-op над пустой транзакцией. Теперь та же операция, сделанная ВЕРНО:

// ВЕРНО — каждая запись идёт через собственный manager транзакции.
async transferFunds(fromId: string, toId: string, amount: number) {
  await this.dataSource.transaction(async (manager) => {
    const accounts = manager.getRepository(Account);            // привязан к ЭТОЙ tx
    await accounts.decrement({ id: fromId }, 'balance', amount);
    if (amount > 1000) throw new Error('needs approval');       // теперь откатит списание
    await accounts.increment({ id: toId }, 'balance', amount);
  }); // коммитит, только если ОБЕ записи прошли
}

Если у тебя уже есть инжектированный repository и ты хочешь переиспользовать его транзакционно, manager.withRepository(this.accountRepo) перепривязывает именно этот repository (с кастомными методами) к транзакционному manager. Альтернатива для команд, которым противно протаскивать manager через каждый метод, — библиотека транзакционного контекста: typeorm-transactional, построенная на AsyncLocalStorage, где декоратор @Transactional() хранит активный manager в async-контексте, и инжектированные репозитории прозрачно его подхватывают. Это держит код сервиса чистым ценой ещё одной зависимости и пропатченного DataSource.

ПодходКто владеет commit/rollbackАтомарность на нескольких записях?Контроль уровня изоляцииГлавный риск
Инжектированный repo (без tx)Каждый вызов авто-коммититНет — каждая запись своя txНикакогоТихие частичные коммиты
dataSource.transaction + manager.getRepositoryTypeORM (авто при resolve/throw)Да — общий managerОпционально первым аргументомЗабыть использовать manager
Ручной QueryRunnerТы — commit/rollback/releaseДа — queryRunner.managerПолный (startTransaction, savepoint’ы)Утечка соединения (нет release)
Библиотека @Transactional()Декоратор (AsyncLocalStorage)Да — инжектированные repo авто-входятОпция декоратораЛишняя зависимость + патч DataSource

Уровни изоляции, ретраи и миграции

Дефолтный уровень изоляции в Postgres и MySQL — READ COMMITTED: каждый стейтмент видит строки, закоммиченные до его выполнения, что нормально для большинства записей, но допускает non-repeatable reads. Когда операция должна обеспечить инвариант по строкам, которые она читает, а затем пишет (нет овердрафта, нет двойного бронирования), поднимай его до SERIALIZABLE (или используй явные блокировки строк). Более строгая изоляция жертвует пропускной способностью и может прерывать транзакции, поэтому SERIALIZABLE-транзакция обязана быть повторяемой: при serialization failure (Postgres 40001) или deadlock (40P01) лови, делай backoff и переигрывай весь 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 -> ретрай
      throw err;
    }
  }
  throw new Error('transfer failed after retries');
}

Последняя дисциплина: схема, в которую пишут эти репозитории, принадлежит миграциям под контролем версий, сгенерированным через typeorm migration:generate и прогоняемым на деплое. Никогда не выкатывай с synchronize: true — он диффает сущности против живой схемы на старте и может тихо дропнуть столбцы. Миграции ревьюабельны, упорядочены и обратимы; runtime-sync — это продакшен-инцидент, ждущий неудачного запуска.

Почему это работает

Почему ловушка частичного коммита невидима до продакшена? Потому что баг проявляется только когда что-то выбрасывает между двумя записями — а твои тесты почти никогда этого не делают. Тест счастливого пути переводит валидную сумму, обе записи «успешны» (каждая авто-коммитится на своём соединении), балансы выглядят верно, зелёная галочка. Только реальный сбой посреди операции — нарушение ограничения, таймаут, правило одобрения — разрывает две записи и обнажает, что они никогда не были в одной транзакции. Код читается как атомарный, тайпчекер доволен, набор тестов молчит. Вот почему «оно обёрнуто в транзакцию» — не доказательство атомарности; доказательство — «каждая запись идёт через один и тот же manager».

Выбери лучший вариант

Нужно списать с одного счёта и зачислить на другой так, чтобы пара была атомарной — обе прошли или ни одна. Как ты выполнишь две записи?

Викторина

Внутри dataSource.transaction(async (manager) => { ... }) ты вызываешь save() у инжектированного @InjectRepository(User) repo, и следующая строка выбрасывает. Что произойдёт?

Викторина

Ты выполняешь многошаговую запись через ручной QueryRunner и SERIALIZABLE-транзакцию. Какие две вещи обязательно сделать правильно?

Вспомните перед уходом
  1. 01
    Объясни ловушку частичного коммита: почему вызов инжектированного @InjectRepository repo внутри dataSource.transaction не атомарен и как это починить?
  2. 02
    Сопоставь форму-колбэк и форму QueryRunner для транзакций и скажи, когда поднимать изоляцию и как обрабатывать последствия.
Итог

Repository — это граница персистентности между твоим доменом и базой, и инжектированный @InjectRepository repo привязан к дефолтному EntityManager у DataSource — каждый вызов авто-коммитится на своём соединении, что верно для одного чтения или записи. Unit of work — это одна атомарная бизнес-операция, отображённая ровно в одну транзакцию, где ВСЕ записи идут через ОДИН И ТОТ ЖЕ транзакционный manager. TypeORM предлагает две формы транзакций: колбэк dataSource.transaction(async (manager) => …), который авто-коммитит при resolve и авто-откатывает при throw, и ручной QueryRunner (connect, startTransaction, commit/rollbackTransaction, release в finally) для явного контроля и изоляции. Сеньорская ловушка: вызов инжектированного repo внутри dataSource.transaction НЕ входит в эту транзакцию — запись авто-коммитится на дефолтном manager, так что throw посреди операции оставляет частичный коммит (списано, но не зачислено), невидимый до продакшена, потому что тесты редко выбрасывают между записями. Фиксы — гнать записи через manager.getRepository / manager.withRepository внутри колбэка или использовать библиотеку контекста @Transactional() на AsyncLocalStorage. Поднимай изоляцию до SERIALIZABLE для обеспечения инвариантов, ретрай при serialization failure и deadlock, и держи схему в миграциях под контролем версий, а не в runtime-synchronize. Теперь, когда читаешь сервис и видишь инжектированный @InjectRepository, используемый внутри колбэка dataSource.transaction, — ты знаешь точно, какой тихий риск несёт этот код и как его починить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.