Transactional Patterns
Use when defining or debugging Spring Boot 4 database transaction boundaries, rollback, propagation, optimistic-lock retries, or commit-bound events. For duplicate HTTP commands use idempotency-patterns; for reactive transactions use webflux-reactive-patterns.
View SKILL.mdEstablish the boundary
- Inspect the project's Boot/Framework version, transaction manager, persistence technology, rollback configuration and caller before changing propagation.
- Place multi-step business transactions at the application/service boundary. Preserve Spring Data repository transactions; repository annotations are not inherently wrong.
- Default propagation is
REQUIRED— joins existing transaction or creates one - Use
readOnly = truefor appropriate read units of work. It is an optimization hint, not write prevention, authorization or automatic routing to a read replica. - Keep existing architecture and transaction-manager choices. A local database transaction does not make writes to another database or an HTTP provider atomic.
The snippets below are illustrative; domain classes and imports come from the application. Adapt the service template and compare the good and bad examples when editing a service.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default for all methods in this service
public class OrderService {
@Transactional // overrides readOnly for writes
public Order createOrder(CreateOrderRequest request) {
inventoryService.reserve(request.items()); // participates in same TX
return orderRepository.save(Order.from(request));
}
public Optional<Order> findById(UUID id) {
return orderRepository.findById(id); // readOnly = true inherited
}
}
Propagation
| Propagation | Behavior |
|---|---|
REQUIRED (default) |
Join existing TX or create new |
REQUIRES_NEW |
Always create new TX, suspend existing |
SUPPORTS |
Join if exists, proceed without TX if not |
NOT_SUPPORTED |
Always run without TX |
MANDATORY |
Must have existing TX, throw if not |
NEVER |
Must NOT have TX, throw if one exists |
Participating REQUIRED calls use the outer transaction's isolation, timeout and read-only
settings. Catching an inner failure does not clear its rollback-only marker; outer commit
may still throw UnexpectedRollbackException.
REQUIRES_NEW needs another connection while the outer transaction retains its resources.
Account for pool capacity and lock contention. Reserve independent commits for records
that must survive a failed operation (for example, an attempted action), not a success
audit row that claims a rolled-back order was created. Do not reference an uncommitted
parent row from an independent audit transaction.
// REQUIRES_NEW — for audit logging that must survive rollback
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
auditRepository.save(event); // commits independently of parent TX
}
// Illustrative: record an attempt for an existing order; failures are unchecked.
@Transactional
public void processOrder(Order order) {
auditService.logAuditEvent(new AuditEvent("ORDER_START", order.getId()));
try {
// ... process, may throw
} catch (RuntimeException e) {
auditService.logAuditEvent(new AuditEvent("ORDER_FAILED", order.getId()));
throw e; // parent TX rolls back, audit TX already committed
}
}
Self-Invocation Pitfall
// ❌ BROKEN — self-invocation bypasses Spring proxy, @Transactional ignored
@Service
public class OrderService {
@Transactional
public void processAll(List<UUID> ids) {
ids.forEach(id -> this.processSingle(id)); // bypasses proxy!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingle(UUID id) { ... } // never creates new TX
}
// ✅ FIX — extract the independently transactional operation to another bean
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderProcessor orderProcessor; // separate bean
@Transactional
public void processAll(List<UUID> ids) {
ids.forEach(id -> orderProcessor.processSingle(id)); // goes through proxy
}
}
Handling Exceptions
// Default: RuntimeException and Error roll back; checked exceptions do not.
// Check project-wide rollback configuration before adding per-method rules.
@Transactional(rollbackFor = InsufficientInventoryException.class) // checked exception
public Order createOrder(CreateOrderRequest request) throws InsufficientInventoryException {
...
}
Never use noRollbackFor to recover from OptimisticLockException: the persistence
provider marks the transaction rollback-only. Roll it back and retry the whole unit of
work in a fresh transaction, with a bounded policy and a fresh entity read.
Optimistic Locking
@Entity
public class Order {
@Version
private Long version; // Hibernate handles conflicts automatically
}
// Handles concurrent updates
@Transactional
public Order updateStatus(UUID id, OrderStatus newStatus) {
Order order = orderRepository.findById(id).orElseThrow();
order.updateStatus(newStatus); // conflict can surface at flush or commit
return orderRepository.save(order);
}
Retrying Transient Failures
Retry support ships in core Spring Framework (org.springframework.resilience.annotation) — no
Spring Retry dependency. Enable once with @EnableResilientMethods, then retry transient failures
such as optimistic-lock conflicts:
@Configuration
@EnableResilientMethods
public class ResilienceConfig {}
@Service
@RequiredArgsConstructor
public class OrderStatusFacade {
private final OrderService orderService; // separate bean — retry must wrap the TX
@Retryable(includes = ObjectOptimisticLockingFailureException.class,
maxRetries = 3, delay = 50, jitter = 25)
public Order updateStatus(UUID id, OrderStatus newStatus) {
return orderService.updateStatus(id, newStatus); // fresh @Transactional per attempt
}
}
Put @Retryable on a method that calls the @Transactional method on another bean — each attempt
needs a fresh transaction. Retrying inside the failed transaction re-runs code in a TX already marked
rollback-only. For hot write paths, @ConcurrencyLimit(10) (same package) caps concurrent invocations
instead of letting contention turn into retry storms.
Use the version-specific retry guidance for dependencies
and annotation imports. A retrying facade must not already hold a transaction that each
attempt's REQUIRED method would join.
Distributed Transactions (Saga Pattern)
A saga coordinates durable local steps; wrapping remote calls in @Transactional
does not implement one.
- Commit the pending order, saga state and outgoing command in one local transaction.
- Dispatch the command outside that transaction, using a stable provider idempotency key.
- Persist each confirmed outcome and the next command atomically in another local transaction.
- On rejection, persist failure state and a compensation command. Retry compensation independently, and reconcile timeouts where the remote outcome is unknown.
Do not save failure state and then throw a rollback-triggering exception from the same transaction: that erases the state. Recovery must resume from durable state after a crash. Use the project's existing workflow mechanism; see messaging/outbox and idempotency for delivery and duplicate effects.
Side Effects After Commit
For best-effort local notifications, a commit-bound listener prevents delivery on rollback.
For required delivery, persist an outbox entry with the business change or use the project's
durable publication mechanism. AFTER_COMMIT alone cannot recover a process crash or retry
a failed delivery.
// Publisher — inside the TX
@Transactional
public Order place(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
order.place();
eventPublisher.publishEvent(new OrderPlaced(order.getId())); // published now; listener defers handling
return orderRepository.save(order);
}
// Listener — runs ONLY if the TX commits successfully
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlaced event) {
emailService.sendConfirmation(event.orderId()); // best effort; no durable retry
}
AFTER_COMMIT runs after the DB commits, but resources may still be bound to the completed
transaction. Delegate database writes to a separate REQUIRES_NEW bean. Listener failure
cannot undo the committed business write; make delivery failures observable and recoverable.
Without an active transaction the listener is skipped by default. @Async does not add
durable delivery. See domain events.
Verification
Test through Spring proxies and verify committed data from a new transaction. Cover checked versus unchecked failures, inner rollback-only propagation, optimistic conflicts at commit, audit rollback, and listener behavior after commit and rollback. Use the production database engine for isolation and contention tests. A test-wide rollback can hide after-commit behavior.
Gotchas
- Agent removes repository transaction annotations - keep the unit of work and existing Spring Data behavior.
- Agent treats AFTER_COMMIT as durable messaging - use an outbox or persistent publication registry for required delivery.
- Agent uses readOnly for replica routing or write protection - neither follows from the flag alone.
- Agent commits success audit entries with REQUIRES_NEW - commit them with the business mutation.
- Agent suppresses optimistic-lock rollback - retry in a fresh transaction.
- Agent calls
@Transactionalmethods onthis— self-invocation bypasses proxy - Agent expects checked exceptions to rollback — must add
rollbackFor - Agent uses
@Transactionalonprivatemethods — Spring proxy can't intercept - Agent pulls in
spring-retryfor a new Boot 4 retry path - use core@Retryableand@EnableResilientMethods; preserve existing integrations when outside the task's scope. - Agent relies on unspecified retry/transaction advisor ordering - verify every attempt gets a fresh transaction; a separate retry facade makes the boundary explicit.
Official sources
Examples & templates
bad-transactional-service.javaexamples
// ❌ BAD — every common @Transactional mistake
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public Order findById(UUID id) { // ambiguous not-found contract
return orderRepository.findById(id).orElse(null);
}
@Transactional
public void processOrders(List<UUID> orderIds) {
for (UUID id : orderIds) {
this.processSingle(id); // joins outer TX; no independent transaction
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW) // never reached via self-invocation
private void processSingle(UUID id) { // private method — proxy can't intercept
Order order = orderRepository.findById(id).orElseThrow();
order.process();
}
@Transactional
public void createOrder(CreateOrderRequest request) {
try {
Order order = Order.create(request.customerEmail());
orderRepository.save(order);
riskyExternalCall(order);
} catch (Exception e) {
log.error("Failed", e); // hides failure; may commit or remain rollback-only
}
}
@Transactional // missing rollbackFor = Exception.class
public void updateOrder(UUID id, UpdateOrderRequest request) throws BusinessException {
Order order = orderRepository.findById(id).orElseThrow();
order.update(request);
// checked exception won't trigger rollback without rollbackFor
externalService.notify(order);
}
}good-transactional-service.javaexamples
// ✅ GOOD — business mutation and success audit commit or roll back together.
// Illustrative: imports and domain types are supplied by the application.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final OrderAuditService auditService; // separate bean to avoid self-invocation
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public Page<Order> findAll(Pageable pageable) {
return orderRepository.findAll(pageable);
}
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request.customerEmail());
order = orderRepository.save(order);
auditService.logCreation(order); // same transaction; no success record on rollback
return order;
}
@Transactional(rollbackFor = Exception.class)
public void cancel(UUID id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.cancel();
auditService.logCancellation(order);
}
}
@Service
@RequiredArgsConstructor
public class OrderAuditService {
private final AuditLogRepository auditLogRepository;
@Transactional(propagation = Propagation.MANDATORY)
public void logCreation(Order order) {
auditLogRepository.save(AuditLog.orderCreated(order));
}
@Transactional(propagation = Propagation.MANDATORY)
public void logCancellation(Order order) {
auditLogRepository.save(AuditLog.orderCancelled(order));
}
}TransactionalOrderService.javatemplates
package com.example.order.service;
import com.example.order.dto.request.CreateOrderRequest;
import com.example.order.entity.Order;
import com.example.order.exception.OrderNotFoundException;
import com.example.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default: all public methods are read-only
public class TransactionalOrderService {
private final OrderRepository orderRepository;
private final OrderEventPublisher eventPublisher;
// Read-only hint; replica routing requires separate infrastructure.
public Order findById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Inherits class-level readOnly = true
public Page<Order> findAll(Pageable pageable) {
return orderRepository.findAll(pageable);
}
// Override: write transaction with explicit rollback for checked exceptions
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
Order order = Order.create(request.customerEmail());
order = orderRepository.save(order);
eventPublisher.publishOrderCreated(order);
return order;
}
// Override: write transaction
@Transactional(rollbackFor = Exception.class)
public void cancel(UUID id) {
Order order = findById(id);
order.cancel();
eventPublisher.publishOrderCancelled(order);
}
}
/**
* Success audit entries must commit or roll back with the business operation.
* MANDATORY prevents accidental use outside its transaction.
* This template requires the application's domain, repository and audit types.
*/
@Slf4j
@Service
@RequiredArgsConstructor
class OrderEventPublisher {
private final AuditLogRepository auditLogRepository;
@Transactional(propagation = Propagation.MANDATORY)
public void publishOrderCreated(Order order) {
auditLogRepository.save(AuditLog.orderCreated(order));
log.debug("Creation audit staged for order {}", order.getId());
}
@Transactional(propagation = Propagation.MANDATORY)
public void publishOrderCancelled(Order order) {
auditLogRepository.save(AuditLog.orderCancelled(order));
log.debug("Cancellation audit staged for order {}", order.getId());
}
}Verification scope
- Compiled sources
- No
- Runtime checks
- Fixture rollback only; no full skill-specific suite
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 4 skills
Install Boot 4 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-4-skills@spring-boot-skills