Опирается на правила: R-TYPEORM-TX-1R-TYPEORM-TX-3 и R-TYPEORM-TX-X1R-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) бросит InsufficientFundsExceptiondataSource.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.