| name | transactional-patterns |
| description | Use when working with @Transactional, multi-step database operations, distributed transactions, or any code that needs atomicity guarantees. Covers propagation rules, isolation levels, read-only optimization, and common pitfalls.
|
Transactional Patterns
Basic Rules
@Transactional belongs on service methods, never controllers or repositories
- Default propagation is
REQUIRED — joins existing transaction or creates one
- Always use on methods that write to the DB or coordinate multiple writes
@Transactional(readOnly = true) on all read-only service methods — enables optimizations
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
@Transactional
public Order createOrder(CreateOrderRequest request) {
inventoryService.reserve(request.items());
return orderRepository.save(Order.from(request));
}
public Optional<Order> findById(UUID id) {
return orderRepository.findById(id);
}
}
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 |
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditEvent(AuditEvent event) {
auditRepository.save(event);
}
@Transactional
public void processOrder(Order order) {
auditService.logAuditEvent(new AuditEvent("ORDER_START", order.getId()));
try {
} catch (Exception e) {
auditService.logAuditEvent(new AuditEvent("ORDER_FAILED", order.getId()));
throw e;
}
}
Self-Invocation Pitfall
@Service
public class OrderService {
@Transactional
public void processAll(List<UUID> ids) {
ids.forEach(id -> this.processSingle(id));
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingle(UUID id) { ... }
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderProcessor orderProcessor;
@Transactional
public void processAll(List<UUID> ids) {
ids.forEach(id -> orderProcessor.processSingle(id));
}
}
Handling Exceptions
@Transactional(rollbackFor = InsufficientInventoryException.class)
public Order createOrder(CreateOrderRequest request) throws InsufficientInventoryException {
...
}
@Transactional(noRollbackFor = OptimisticLockException.class)
public void updateWithRetry(UUID id) { ... }
Optimistic Locking
@Entity
public class Order {
@Version
private Long version;
}
@Transactional
public Order updateStatus(UUID id, OrderStatus newStatus) {
Order order = orderRepository.findById(id).orElseThrow();
order.updateStatus(newStatus);
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;
@Retryable(includes = ObjectOptimisticLockingFailureException.class,
maxRetries = 3, delay = 50, jitter = 25)
public Order updateStatus(UUID id, OrderStatus newStatus) {
return orderService.updateStatus(id, newStatus);
}
}
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.
Distributed Transactions (Saga Pattern)
For multi-service operations, use the Saga pattern instead of distributed TX:
@Service
@RequiredArgsConstructor
public class OrderSaga {
@Transactional
public void execute(CreateOrderRequest request) {
Order order = orderRepository.save(Order.create(request));
try {
inventoryClient.reserve(request.items());
paymentClient.charge(order.getId(), request.total());
order.confirm();
orderRepository.save(order);
} catch (PaymentException e) {
inventoryClient.release(request.items());
order.fail("Payment failed");
orderRepository.save(order);
throw e;
}
}
}
Side Effects After Commit
Never fire an external side effect (email, Kafka publish, webhook, cache warm) inside the transaction —
if the TX rolls back, you've already sent it. Bind the side effect to the commit instead:
@Transactional
public Order place(UUID id) {
Order order = orderRepository.findById(id).orElseThrow();
order.place();
eventPublisher.publishEvent(new OrderPlaced(order.getId()));
return orderRepository.save(order);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlaced event) {
emailService.sendConfirmation(event.orderId());
}
AFTER_COMMIT runs after the DB commits. Note: it runs outside the original transaction, so a
new @Transactional(REQUIRES_NEW) is needed if the listener itself writes to the DB. This is the
clean way to publish the domain events collected in the [[domain-driven-design]] aggregate.
Gotchas
- Agent puts
@Transactional on controllers — only on service layer
- Agent sends email / publishes events inside the TX — use
@TransactionalEventListener(AFTER_COMMIT)
- Agent forgets
readOnly = true on read methods — missed DB optimization
- Agent calls
@Transactional methods on this — self-invocation bypasses proxy
- Agent expects checked exceptions to rollback — must add
rollbackFor
- Agent uses
@Transactional on private methods — Spring proxy can't intercept
- Agent pulls in
spring-retry + @EnableRetry — retry is core framework now: @Retryable + @EnableResilientMethods (attributes are includes/maxRetries/delay, not Spring Retry's retryFor/maxAttempts)
- Agent stacks
@Retryable and @Transactional on the same method — the retry re-runs inside the doomed TX; put @Retryable on the calling bean