Lesson 19 / 25
Transactions with Spring and JPA
Use @Transactional correctly: boundaries, propagation, rollback rules and proxy pitfalls.
Where a unit of work begins and ends
Every JPA write must happen inside a transaction, and reads should too. In Spring, annotate service methods with @Transactional: Spring's proxy begins a transaction before the method, binds an EntityManager to it, and commits (flushing changes) when the method returns, or rolls back on failure. Put transactions at the service layer, around one business operation, not in controllers or on every repository call separately. Rollback rules: by default Spring rolls back for unchecked exceptions (RuntimeException, Error) but commits for checked exceptions unless you set rollbackFor. Propagation controls nesting: REQUIRED (default, join an existing transaction or start one), REQUIRES_NEW (suspend the current one and start an independent one, useful for audit logs that must survive a rollback), MANDATORY, SUPPORTS and others. A notorious pitfall: self-invocation. Calling a @Transactional method from another method in the same class bypasses the proxy, so the annotation has no effect. Annotations on private methods are also ignored by proxy-based configurations.
A transactional proxy
Calls from outside go through the proxy, which opens and commits the transaction; internal calls skip it.
Transaction boundaries, rollback rules and self-invocation
The audit log commits independently; the internal call misses the proxy.
@Service
public class CheckoutService {
private final OrderRepository orders;
private final AuditService audit; // separate bean: proxy applies
public CheckoutService(OrderRepository orders, AuditService audit) {
this.orders = orders; this.audit = audit;
}
@Transactional(rollbackFor = PaymentDeclinedException.class) // checked exception -> rollback
public Long checkout(Cart cart) throws PaymentDeclinedException {
PurchaseOrder order = PurchaseOrder.from(cart);
orders.save(order);
audit.record("checkout attempted", order.getId()); // REQUIRES_NEW: survives rollback
chargeCard(order); // may throw PaymentDeclinedException
return order.getId();
}
public void retryLater(Long orderId) {
markForRetry(orderId); // SELF-INVOCATION: @Transactional below is ignored
}
@Transactional
public void markForRetry(Long orderId) { /* runs without a transaction when called above */ }
}
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event, Long orderId) { /* insert audit row */ }
}Keep transactions short
Do not call slow external APIs or wait for user input inside a database transaction. Long transactions hold locks and connections; gather data first, then open a short transaction to write.
Quick check: A checked exception escapes a Spring @Transactional method without rollbackFor. What happens by default?
- The transaction is committed
- The transaction is rolled back
- Spring retries the method
- The application stops
Answer
The transaction is committed — By default only unchecked exceptions and errors trigger rollback.