Опирается на правила:
R-JOOQ-TX-1…R-JOOQ-TX-3иR-JOOQ-TX-X1…R-JOOQ-TX-X3из jOOQ Style Guide → раздел 10. Транзакции.
Важно знать
- Граница транзакции = граница бизнес-операции.
@Transactionalставим наUseCaseHandler.handle(), не на репозитории, не на сервисе.- Query-handler'ы —
@Transactional(readOnly = true). Spring передаст это драйверу, PG может отдать read на standby.- Default isolation —
READ_COMMITTED(PG-дефолт). Меняем только когда явно нужно (REPEATABLE_READ, SERIALIZABLE) с retry-логикой на40001.@Transactionalна репозитории — запрещён. Репозиторий не знает про бизнес-границы.dslContext.transaction(...)внутри Spring-@Transactional— антипаттерн. Создаёт вложенную TX через Savepoint без надобности.- Self-invocation
@Transactionalне работает (Spring проксирует только внешний вход). Поведение — общая транзакция или ничего.
Транзакция — это атомарный кусок работы с БД. В UCP бизнес-операция = use case (CreateOrder, CancelTicket, PayInvoice). Use case реализуется одним handler'ом. Поэтому транзакция = handler. Это даёт чёткое правило, где ставить @Transactional, и снимает все «а где же тут граница» вопросы. Раскрытие правил R-JOOQ-TX-* ниже.
@Transactional на handler'е
R-JOOQ-TX-1: аннотация ставится на класс или метод UseCaseHandler.
@Component
@Transactional // ← RW по умолчанию
@RequiredArgsConstructor
public class CreateOrderCommandHandler
implements UseCaseHandler<CreateOrderCommand, Order> {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final OutboxEventPublisher outbox;
@Override
public Order handle(CreateOrderCommand cmd) {
Product product = productRepository.findById(cmd.productId(), SelectMode.FOR_UPDATE)
.orElseThrow();
Order order = Order.create(cmd, product);
orderRepository.save(order);
outbox.publish(new OrderCreatedEvent(order.id()));
return order;
}
}
Что это даёт:
- Чёткая граница. Всё внутри
handle()атомарно. Если на любой строчке упало — откатится всё, включая outbox-запись. - Lock'и живы.
FOR UPDATEна product удерживается до выхода из handler'а (см. Lock-режимы). - Одно место для трассировки. Span на handler'е == span транзакции. В Jaeger видна вся операция как единое целое.
readOnly для query-handler'ов
R-JOOQ-TX-2: query-handler — обязательно readOnly = true.
@Component
@Transactional(readOnly = true) // ← обязательно
@RequiredArgsConstructor
public class GetOrdersQueryHandler
implements UseCaseHandler<GetOrdersQuery, PaginationView<OrderView>> {
private final OrderViewRepository viewRepository;
private final OrderViewMapper mapper;
@Override
public PaginationView<OrderView> handle(GetOrdersQuery query) {
return viewRepository.findSummaries(query.filter(), query.page(), query.size())
.map(mapper::toView);
}
}
Что даёт readOnly:
- Spring передаёт driver'у hint. PG может отдать запрос на standby (если настроена репликация и
default_transaction_read_onlyна standby). Это разгружает primary. - JPA-флаги дисциплины. Хотя мы не используем JPA, Spring's transaction manager не открывает Hibernate-flush на read-only — экономит memory.
- Видимый интент в коде. Кто угодно, читая handler, видит «эта операция точно ничего не пишет». Если случайно вызвать
repository.save(...)— будет ошибка от PG (cannot execute UPDATE in a read-only transaction).
Isolation level — только когда явно надо
R-JOOQ-TX-3: дефолт — READ_COMMITTED (PG-дефолт). Поднимаем точечно.
@Component
@Transactional(isolation = Isolation.REPEATABLE_READ)
public class TransferMoneyCommandHandler { /* ... */ }
Когда нужен более высокий уровень:
REPEATABLE_READ— отчётность за период, нужны консистентные данные на момент старта транзакции (см. ACID и уровни изоляции).SERIALIZABLE— денежные операции с инвариантом на нескольких строках (write skew, см. тот же раздел).
Условие применения: retry-логика на SQLSTATE 40001 (serialization_failure). Без неё код будет периодически падать на пользователе. См. FAQ в ACID-статье — про @Retryable(retryFor = ConcurrencyFailureException.class, ...).
Self-invocation — ловушка
Spring @Transactional работает через AOP-прокси. Когда внешний код зовёт handler.handle(...), он на самом деле зовёт прокси, который открывает транзакцию, делает invoke, коммитит.
Но если внутри того же класса один метод зовёт другой через this.otherMethod() — прокси не задействован. @Transactional на otherMethod игнорируется.
@Component
public class SomeHandler {
@Transactional
public void outerMethod() {
innerMethod(); // ← вызов через this — прокси не задействован
}
@Transactional(timeout = 60)
public void innerMethod() {
// ← аннотация ИГНОРИРУЕТСЯ при self-invocation
}
}
То же самое касается:
- Timeout — игнорируется при вложенном вызове (см. ACID-статья → FAQ).
- Isolation — игнорируется.
- Propagation = REQUIRES_NEW — игнорируется, всё ещё одна транзакция.
Решение: если нужна разная propagation/isolation — раздели на два бина. Один зовёт другой через DI.
Что запрещено
R-JOOQ-TX-X1: @Transactional на репозитории.
// ПЛОХО
@Repository
@Transactional // ← НЕТ
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {
@Override
@Transactional(readOnly = true) // ← И ТУТ НЕТ
public Optional<Order> findById(Long id, SelectMode mode) { /* ... */ }
}
Почему: репозиторий не знает про бизнес-границы. Один бизнес-сценарий вызывает 3 метода репозитория — они должны быть в одной транзакции, а не в трёх. Если поставить @Transactional на репозитории, каждый вызов будет открывать свою — лок не сохранится между ними, изоляция размажется.
R-JOOQ-TX-X2: @Transactional на сервисном слое в обход handler'ов.
У UCP handler — единственная точка транзакции. Если в проекте появляется отдельный «service layer» с @Transactional, это размывает архитектуру: где граница? Один handler зовёт сервис, сервис открывает свою транзакцию, handler сверху — тоже свою? Получается матрёшка из propagation.
Один use case = один handler = одна @Transactional. Просто.
R-JOOQ-TX-X3: программное управление транзакцией через dslContext.transaction(...).
// ПЛОХО — внутри Spring-@Transactional делать ещё одну транзакцию
@Transactional
public void handle(SomeCommand cmd) {
dslContext.transaction(ctx -> { // ← ненужная вложенность
// ...
});
}
dslContext.transaction(...) внутри @Transactional создаст вложенную транзакцию через savepoint (PG не поддерживает настоящие nested-транзакции). Это:
- Лишняя сложность — два уровня rollback'а, неочевидное поведение.
- Лишний savepoint per request — overhead.
- Никакой выгоды — Spring уже управляет транзакцией.
Когда dslContext.transaction(...) оправдан: не внутри handler'а — например, в bootstrap-скрипте или batch-task'е, где нет Spring-проводки и нужно явное управление. На handler'е — никогда.
Куда дальше
- jOOQ Style Guide → раздел 10. Транзакции — нормативные формулировки.
- Lock-режимы — почему
FOR UPDATEобязан быть внутри@Transactional. - ACID и уровни изоляции — выбор isolation level, retry-логика, ловушки self-invocation.
- Repository pattern в jOOQ — почему репозиторий без
@Transactional.