Опирается на правила: PG-TX-001PG-TX-082, антипаттерны PG-TX-090PG-TX-097 из PostgreSQL Style Guide → раздел Spring @Transactional.

Важно знать

  • Ставь на бизнес-методах UseCase/Handler, не на репозиториях, не на контроллере.
  • Self-invocation не работает — вызов через this.method() идёт мимо AOP proxy.
  • Метод должен быть public — на private/protected тихо не работает.
  • REQUIRED (default) почти всегда; REQUIRES_NEW — отдельная TX + отдельное соединение.
  • readOnly = truesetReadOnly + skip flush + routing на read-replica.
  • rollbackFor — Spring default откатывает только RuntimeException/Error. Checked не откатывают.
  • Лучше не бросать checked из transactional, оборачивать в runtime.
  • @Transactional не покрывает Kafka/HTTP/Redis — Outbox.
  • TX должна жить секунды, не минуты — внешние HTTP вне транзакции.

@Transactional — самая частая аннотация и самый частый источник тонких багов. UCP формулирует — что обычно неочевидно: propagation, self-invocation, rollback, async.

Где ставить

PG-TX-001..002:

// ✓ — на UseCase/Handler
@Component
class CreateOrderUseCaseHandler {
    @Transactional
    public OrderId handle(CreateOrderUseCase uc) {
        var customer = customerRepository.findById(uc.customerId());
        var order = orderFactory.create(customer, uc.items());
        orderRepository.save(order);
        outboxRepository.publishEvent(new OrderCreated(order.id()));
        return order.id();
    }
}

// ✗ — НЕ на репозитории (избыточно)
// ✗ — НЕ на контроллере (HTTP ≠ бизнес-операция)

Граница TX = граница бизнес-операции. Один UseCase = одна транзакция.

Self-invocation — главная грабля

PG-TX-010..013:

// ✗ — НЕ работает
@Component
class OrderService {
    public void processBatch(List<OrderId> ids) {
        for (var id : ids) {
            processOne(id);   // НЕ открывает транзакцию!
        }
    }

    @Transactional
    public void processOne(OrderId id) { ... }
}

Spring AOP proxy не перехватывает внутренние вызовы. Решение — отдельный бин:

@Component
class OrderService {
    private final OrderProcessor processor;

    public void processBatch(List<OrderId> ids) {
        for (var id : ids) {
            processor.processOne(id);   // через DI — через прокси — TX открывается
        }
    }
}

@Component
class OrderProcessor {
    @Transactional
    public void processOne(OrderId id) { ... }
}

Метод обязан быть public — Spring AOP не видит package-private/protected/private. На приватном — ничего не открывается тихо.

Propagation

PG-TX-020..025:

PropagationКогда
REQUIRED (default)Если TX есть — присоединиться. Иначе — открыть. Почти всегда.
REQUIRES_NEWПриостановить текущую, открыть новую. Аудит, outbox/event log.
NESTEDSavepoint внутри TX. Опциональные шаги.
MANDATORYДолжна быть открыта TX, иначе exception. Helper-методы.
NEVER / NOT_SUPPORTEDПочти не нужны.

REQUIRES_NEW

@Transactional
public void processPayment(...) {
    auditService.logAttempt(...);   // должен записаться даже если processPayment упал
    paymentGateway.charge(...);
}

@Component
class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAttempt(...) { ... }
}

REQUIRES_NEW берёт отдельное соединение из пула. Текущее остаётся занятым (suspended). На high-throughput удваивает потребление пула.

NESTED — savepoint

@Transactional
public void importBatch(List<Item> items) {
    for (var item : items) {
        try {
            processor.saveItem(item);   // NESTED — savepoint
        } catch (Exception e) {
            log.warn("skip {}: {}", item, e);
        }
    }
}

@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item i) { ... }

Откат внутреннего savepoint оставляет внешнюю TX живой.

readOnly = true

PG-TX-030..033: три эффекта.

  1. Connection.setReadOnly(true) → JDBC передаёт PG: SET TRANSACTION READ ONLY.
  2. JPA/Hibernate — skip dirty checking и flush (экономия CPU).
  3. Routing DataSource — направляет на read-replica (если настроено).
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }

На jOOQ — почти ничего (нет dirty checking). Просто JDBC setReadOnly.

rollbackFor

PG-TX-040..043: исторический Spring-default.

// ✗ — IOException НЕ откатит!
@Transactional
public void doWork() throws IOException {
    repo.save(...);
    if (cond) throw new IOException();   // commit пройдёт
}

Spring откатывает только RuntimeException и Error. Checked — нет.

Решения:

Вариант 1: rollbackFor

@Transactional(rollbackFor = Exception.class)
public void doWork() throws IOException { ... }

Вариант 2: оборачивать в runtime

@Transactional
public void doWork() {
    try {
        externalCall();
    } catch (IOException e) {
        throw new ExternalCallFailedException(e);   // RuntimeException
    }
}

Кода больше, но контракт честный. Все ошибки runtime, default rollback работает.

Транзакции и async

PG-TX-050..051:

// ✗ — текущая TX НЕ передаётся
@Transactional
public void mainOp() {
    repo.save(...);
    asyncSomething();   // выполнится в другом потоке, БЕЗ TX
}

@Async
@Transactional
public CompletableFuture<Void> asyncSomething() { ... }   // открывает СВОЮ TX

CompletableFuture.runAsync(() -> repo.save(...)) без @Async — не транзакционно вообще.

@PostConstruct и события

PG-TX-060..061:

// ✗ — @PostConstruct до полной инициализации, @Transactional не работает
@PostConstruct
@Transactional
public void init() { ... }

// ✓ — ApplicationReadyEvent после инициализации
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initData() { ... }

@TransactionalEventListener

@Component
class OrderEventHandler {
    @TransactionalEventListener   // AFTER_COMMIT (default)
    public void onOrderCreated(OrderCreated event) {
        emailService.send(...);
    }
}

Слушатель привязан к фазе TX. Полезно для action-after-commit без race.

Внешние ресурсы

PG-TX-070..072:

// ✗ — kafkaTemplate.send отправит, даже если TX откатилась
@Transactional
public OrderId createOrder(...) {
    var order = orderRepo.save(...);
    kafkaTemplate.send("orders.created", order);   // ушло, rollback не вернёт
    return order.id();
}

// ✓ — Outbox
@Transactional
public OrderId createOrder(...) {
    var order = orderRepo.save(...);
    outboxRepo.save(new OutboxEvent("OrderCreated", order.id(), payload));
    return order.id();
    // если упадёт — обе строки откатятся вместе
}

@Transactional не покрывает Kafka, HTTP, Redis, файлы. Используй Outbox.

Длительность

PG-TX-080..082: секунды, не минуты.

// ✗ — 5 сек открытая TX, connection занят
@Transactional
public void processOrder(OrderId id) {
    var order = orderRepo.find(id);
    paymentGateway.charge(order.total());      // HTTP, 3s
    deliveryService.scheduleDelivery(order);   // HTTP, 2s
    order.confirm();
    orderRepo.save(order);
}

// ✓ — внешние вызовы вне TX
public void processOrder(OrderId id) {
    var order = orderRepo.find(id);            // короткая TX
    paymentGateway.charge(order.total());
    deliveryService.scheduleDelivery(order);
    confirmOrder(id);                          // короткая TX (отдельный бин!)
}

@Component
class ConfirmService {
    @Transactional
    public void confirmOrder(OrderId id) {
        var order = orderRepo.find(id);
        order.confirm();
        orderRepo.save(order);
    }
}

Для долгих процессов — Saga с компенсациями (см. Distributed → saga).

Что запрещено

АнтипаттернПравилоЧто взамен
@Transactional на private/protectedPG-TX-090public
Вызов @Transactional метода через this.PG-TX-091отдельный бин
@Transactional вокруг HTTP/Kafka/S3PG-TX-092вне TX
rollbackFor = Exception.class глобальноPG-TX-093runtime exceptions
@Transactional на контроллереPG-TX-094на handler
kafkaTemplate.send в TXPG-TX-095Outbox
REQUIRES_NEW без понимания пулаPG-TX-096оценить нагрузку
SERIALIZABLE без retryPG-TX-097@Retryable
@Transactional на репозиторииPG-TX-001на UseCase
Isolation.READ_COMMITTED явноPG-IS-041не указывать (= default)

Куда дальше

  • PG → Spring @Transactional — нормативные формулировки.
  • Уровни изоляции — isolation параметр.
  • Блокировки — SELECT FOR UPDATE в @Transactional.
  • HikariCP — REQUIRES_NEW + пул.
  • Kafka → outbox publishing — Outbox-паттерн.
  • Distributed → saga — long processes.