Репозитории, транзакции и unit of work
Repository привязан к дефолтному manager — вызов инжектированного repo внутри dataSource.transaction НЕ входит в транзакцию. Гони каждую запись через ОДИН транзакционный EntityManager (manager.getRepository), иначе частичный коммит оставит осиротевшие строки.
Канал инцидентов вспыхивает в два часа ночи: клиент перевёл $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.getRepository | TypeORM (авто при 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-транзакцию. Какие две вещи обязательно сделать правильно?
- 01Объясни ловушку частичного коммита: почему вызов инжектированного @InjectRepository repo внутри dataSource.transaction не атомарен и как это починить?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.