Опирается на правила:
R-TYPEORM-TX-1…R-TYPEORM-TX-3иR-TYPEORM-TX-X1…R-TYPEORM-TX-X2из TypeORM Style Guide → раздел 4. Транзакции.
Важно знать
- Граница транзакции — на Handler.
dataSource.transaction(async (em) => ...)вызывается в Handler, не в репозитории.- Внутри транзакции репозитории работают через транзакционный
EntityManager— переданный напрямую или резолвленный из CLS-контекста (typeorm-transactional).- Глобальный
DataSource/Repository<T>внутриdataSource.transaction(...)обходит транзакцию — операции уйдут мимо.- Read-методы — без транзакции. Запись — только через Handler-границу.
queryRunner.startTransaction()/commitTransaction()внутри репозитория — запрещены.- Несколько последовательных
save()без общей транзакции в одной бизнес-операции — частичная запись при сбое.- CLS-подход (
typeorm-transactional) устраняет ручную передачуem, но требует инициализацииinitializeTransactionalContext()на старте.
Транзакция — граница атомарного куска работы с БД. В UCP бизнес-операция = use case (PlaceOrder, CancelOrder, ApplyPayment). Use case реализуется одним Handler'ом. Поэтому транзакция = Handler. Это даёт чёткое правило без вопроса «а где здесь граница?» и снимает целый класс ситуаций, когда два метода репозитория работают в разных транзакциях, теряя атомарность.
В TypeORM Data Mapper-стиле транзакция открывается через dataSource.transaction(async (em) => ...). Всё внутри колбэка разделяет один EntityManager и коммитится или откатывается атомарно. Раскрытие правил R-TYPEORM-TX-* ниже.
dataSource.transaction на handler'е
R-TYPEORM-TX-1: Handler открывает транзакцию, репозитории работают внутри неё.
// adapters/in/order/place-order.handler.ts
@Injectable()
export class PlaceOrderHandler implements UseCaseHandler<PlaceOrderCommand, Order> {
constructor(
private readonly dataSource: DataSource,
private readonly orderRepo: TypeOrmOrderRepository,
private readonly productRepo: TypeOrmProductRepository,
) {}
async handle(cmd: PlaceOrderCommand): Promise<Order> {
return this.dataSource.transaction(async (em) => {
const product = await this.productRepo.findById(em, cmd.productId);
if (!product) throw new ProductNotFoundException(cmd.productId);
product.reserve(cmd.quantity);
await this.productRepo.save(em, product);
const order = Order.create(cmd.customerId, product, cmd.quantity);
await this.orderRepo.save(em, order);
return order;
});
}
}
Что это даёт:
- Чёткая граница. Всё внутри колбэка атомарно. Если
productRepo.saveупало — откатятся и резервирование, и создание заказа. - Один
EntityManagerна всю операцию. Оба репозитория видят одно состояние данных внутри транзакции. - Явный контракт. Методы репозитория принимают
em: EntityManagerпервым аргументом — видно, что операция выполняется в транзакционном контексте.
Метод dataSource.transaction(...) сам открывает соединение, начинает транзакцию, коммитит при успехе и откатывает при любом исключении из колбэка. Явный try/catch/finally не нужен.
Передача EntityManager в репозиторий
R-TYPEORM-TX-2: репозиторий принимает транзакционный EntityManager и работает через него, не через глобальный DataSource.
// adapters/out/persistence/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository {
constructor(private readonly dataSource: DataSource) {}
async findById(em: EntityManager, id: string): Promise<Order | null> {
const entity = await em.findOne(OrderEntity, {
where: { id },
relations: ['items'],
});
return entity ? OrderMapper.toDomain(entity) : null;
}
async save(em: EntityManager, order: Order): Promise<void> {
const entity = OrderMapper.toEntity(order);
await em.save(OrderEntity, entity);
}
}
Важно: em.findOne(...) и em.save(...) выполняются через транзакционный EntityManager, переданный из Handler'а. Если вместо em использовать this.dataSource.getRepository(OrderEntity), запросы уйдут через отдельное соединение — вне транзакции.
Для read-методов вне транзакции репозиторий может принимать em?: EntityManager с fallback на this.dataSource.manager:
async findById(id: string, em?: EntityManager): Promise<Order | null> {
const manager = em ?? this.dataSource.manager;
const entity = await manager.findOne(OrderEntity, {
where: { id },
relations: ['items'],
});
return entity ? OrderMapper.toDomain(entity) : null;
}
CLS-подход через typeorm-transactional
Альтернатива явной передаче em — библиотека typeorm-transactional (CLS-hooked декоратор). Транзакционный EntityManager резолвится из AsyncLocalStorage-контекста автоматически.
// main.ts
import { initializeTransactionalContext } from 'typeorm-transactional';
initializeTransactionalContext();
// adapters/in/order/cancel-order.handler.ts
@Injectable()
export class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, void> {
constructor(
private readonly orderRepo: TypeOrmOrderRepository,
private readonly productRepo: TypeOrmProductRepository,
) {}
@Transactional()
async handle(cmd: CancelOrderCommand): Promise<void> {
const order = await this.orderRepo.findById(cmd.orderId);
if (!order) throw new OrderNotFoundException(cmd.orderId);
order.cancel();
await this.orderRepo.save(order);
}
}
// adapters/out/persistence/typeorm-order.repository.ts (CLS-вариант)
@Injectable()
export class TypeOrmOrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly repo: Repository<OrderEntity>,
) {}
async findById(id: string): Promise<Order | null> {
const entity = await this.repo.findOne({
where: { id },
relations: ['items'],
});
return entity ? OrderMapper.toDomain(entity) : null;
}
async save(order: Order): Promise<void> {
await this.repo.save(OrderMapper.toEntity(order));
}
}
При CLS-подходе typeorm-transactional подменяет Repository<T> так, что this.repo.findOne(...) внутри @Transactional() контекста автоматически использует текущий транзакционный EntityManager. Подпись методов репозитория упрощается — em не передаётся явно.
Оба подхода (явный em и CLS) корректны. Выбор фиксируется на уровне проекта и не смешивается внутри одного сервиса.
Несколько агрегатов в одной транзакции
Типовой сценарий — списание остатка Product и создание Order атомарно. Пример на сбербанковском домене: оформление платёжного поручения обновляет баланс Account и создаёт Payment.
// adapters/in/payment/create-payment.handler.ts
@Injectable()
export class CreatePaymentHandler implements UseCaseHandler<CreatePaymentCommand, Payment> {
constructor(
private readonly dataSource: DataSource,
private readonly accountRepo: TypeOrmAccountRepository,
private readonly paymentRepo: TypeOrmPaymentRepository,
) {}
async handle(cmd: CreatePaymentCommand): Promise<Payment> {
return this.dataSource.transaction(async (em) => {
const account = await this.accountRepo.findById(em, cmd.accountId);
if (!account) throw new AccountNotFoundException(cmd.accountId);
account.debit(cmd.amount);
await this.accountRepo.save(em, account);
const payment = Payment.create(cmd.accountId, cmd.amount, cmd.recipientId);
await this.paymentRepo.save(em, payment);
return payment;
});
}
}
Если account.debit(cmd.amount) бросит InsufficientFundsException — dataSource.transaction откатит транзакцию: ни изменение баланса, ни создание Payment не сохранятся.
Outbox в одной транзакции
Outbox-событие пишется в той же транзакции, что и основные данные. Это гарантирует: событие существует тогда и только тогда, когда существуют данные.
// adapters/in/order/place-order.handler.ts
@Injectable()
export class PlaceOrderHandler implements UseCaseHandler<PlaceOrderCommand, Order> {
constructor(
private readonly dataSource: DataSource,
private readonly orderRepo: TypeOrmOrderRepository,
private readonly outboxRepo: TypeOrmOutboxRepository,
) {}
async handle(cmd: PlaceOrderCommand): Promise<Order> {
return this.dataSource.transaction(async (em) => {
const order = Order.create(cmd.customerId, cmd.productId, cmd.quantity);
await this.orderRepo.save(em, order);
const event = OutboxEvent.create('order.placed', order.id, order);
await this.outboxRepo.save(em, event);
return order;
});
}
}
Если транзакция откатится — ни Order, ни outbox-запись не сохранятся. Relay-сервис не опубликует событие в Kafka.
Read-only запросы — без транзакции
R-TYPEORM-TX-3: read-методы (запросы) выполняются без транзакции. Handler для read-only сценария работает через репозиторий напрямую, без dataSource.transaction.
// adapters/in/order/get-orders.handler.ts
@Injectable()
export class GetOrdersHandler implements UseCaseHandler<GetOrdersQuery, PaginatedResult<OrderView>> {
constructor(private readonly viewRepo: TypeOrmOrderViewRepository) {}
async handle(query: GetOrdersQuery): Promise<PaginatedResult<OrderView>> {
return this.viewRepo.findSummaries(query.customerId, query.page, query.size);
}
}
TypeOrmOrderViewRepository использует DataSource.query(...) или QueryBuilder с getRawMany для проекций в read-DTO. Транзакция не нужна — запрос идёт в рамках одного SELECT, консистентность гарантирует PostgreSQL READ COMMITTED по умолчанию.
Если сценарий читает несколько таблиц и нужна единая точка видимости — можно открыть read-only транзакцию явно, но это редкий случай для отчётов, а не стандартная практика.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
queryRunner.startTransaction() / commitTransaction() / rollbackTransaction() в репозитории | R-TYPEORM-TX-X1 | Транзакция открывается только в Handler через dataSource.transaction |
Несколько save() без общей транзакции в одной бизнес-операции | R-TYPEORM-TX-X2 | Один dataSource.transaction на весь Handler |
Использование глобального DataSource.getRepository(T) внутри dataSource.transaction колбэка | R-TYPEORM-TX-2 | Только транзакционный em, переданный в колбэк |
@Transactional() на методе репозитория | R-TYPEORM-TX-X1 | @Transactional() — только на Handler; репозиторий участвует в существующей транзакции |
Несколько Handler'ов в одной транзакции (shared em) | R-TYPEORM-TX-1 | Один Handler = одна транзакция; координация через события или saga |
Куда дальше
- Repository pattern в TypeORM — доменный порт,
TypeOrm<X>Repository, приёмEntityManagerкак часть контракта адаптера. - Маппинг Entity ↔ domain в TypeORM —
toDomain/toEntity, почему Entity не выходит из репозитория. - PostgreSQL: ACID и уровни изоляции — когда поднимать isolation level выше
READ COMMITTED, retry наSQLSTATE 40001, write skew.