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