Транзакция ограничивает не класс и не функцию save, а бизнес-операцию: создать заказ, списать остаток, записать outbox. Все шаги либо фиксируются, либо откатываются вместе.
Граница сценария
await dataSource.transaction('REPEATABLE READ', async manager => {
const order = manager.create(Order, { status: 'new' });
await manager.save(order);
await manager.save(OutboxEvent, {
type: 'OrderCreated',
aggregateId: order.id,
});
});
Оба save используют один manager и одну транзакцию. Исключение откатывает их, успешный return фиксирует. Если внутри вызвать отдельный dataSource.manager или другой репозиторий, границу можно случайно обойти.
Сетевой вызов платежа или Kafka внутри транзакции не откатывается. Его выносят после commit или используют outbox, а не удерживают соединение во время ожидания внешнего мира.
Isolation выбирают по инварианту
READ COMMITTED подходит для многих операций, но не решает любую гонку. Если читаете остаток и потом изменяете его, конкурирующая транзакция может изменить значение между шагами.
REPEATABLE READ повторяет снимок, но в PostgreSQL аномалии сериализации не исчезают. SERIALIZABLE проверяет опасные взаимодействия и может потребовать повтор транзакции после ошибки сериализации.
Поэтому сначала формулируют инвариант: «остаток не отрицательный», «заказ оплачен один раз». Затем выбирают уровень, блокировки и повторы под него.
Коротко
- Все операции сценария используют один manager.
- Исключение откатывает транзакцию, return фиксирует.
- Kafka и HTTP не входят в откат базы.
- Isolation выбирают по инварианту, а не по привычке.
- Ошибки сериализации требуют ограниченного повтора.
Что почитать дальше
- Блокировки и версии - как не потерять параллельное изменение.
- Миграции - как менять схему атомарно.