@Transactional looks like a switch you flip on a method. In reality it's a contract enforced by a proxy that Spring builds around your bean at startup, and most of the surprising bugs people file against it — the ones where "the annotation isn't working" — come from not understanding that proxy's boundaries.
The Proxy You Don't See
When a bean has a @Transactional method, Spring wraps it in either a JDK dynamic proxy (if it implements an interface) or a CGLIB subclass proxy (if it doesn't). Every call from outside the bean goes through the proxy, which opens a transaction, invokes your real method, and commits or rolls back based on what happened. Every call from inside the bean — one method calling another on this — skips the proxy entirely and calls the real object directly. No proxy, no transaction.
@Service
public class OrderService {
public void placeOrder(Order order) {
// Self-invocation: bypasses the proxy, no transaction starts here
saveOrder(order);
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}The usual fix is to move saveOrder into a separate bean and inject it, or to inject OrderService into itself via @Lazy and call through the proxy. Neither is elegant, but the underlying rule — transactions only apply across bean boundaries — is worth internalizing rather than working around every time.
Propagation Isn't Optional Reading
Propagation.REQUIRED, the default, joins an existing transaction if one is active or starts a new one if not. That covers most CRUD code, but three other settings solve real problems:
REQUIRES_NEWsuspends the caller's transaction and starts an independent one. Use it for audit logging or notification records that must persist even if the enclosing business transaction later rolls back.NESTEDstarts a savepoint inside the current transaction. A failure rolls back to the savepoint, not the whole transaction — useful for "try this, and if it fails, continue without it" logic, but it depends on JDBC savepoint support and doesn't work with every driver.MANDATORYthrows if no transaction is already active, which is a good guardrail for repository methods that should never be called outside a service-layer transaction.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAuditEntry(AuditEvent event) {
auditRepository.save(event);
}Rollback Rules Are Opt-In, Not Automatic
Spring's default rollback policy trips people up constantly: a transaction rolls back automatically on unchecked exceptions (RuntimeException and its subclasses) but commits on checked exceptions unless told otherwise. If your codebase uses checked exceptions for business errors — InsufficientFundsException extends Exception, say — the transaction will happily commit right through the failure.
@Transactional(rollbackFor = InsufficientFundsException.class)
public void transfer(Account from, Account to, BigDecimal amount) throws InsufficientFundsException {
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(from.getId());
}
from.debit(amount);
to.credit(amount);
}The safer long-term pattern is to standardize on unchecked exceptions for anything that should trigger a rollback, and reserve rollbackFor for the exceptions your team hasn't migrated yet. Also remember that catching an exception inside the transactional method and swallowing it prevents rollback entirely — the proxy only sees exceptions that actually propagate out of the method call.
Finally, watch transaction boundaries around read-heavy code. @Transactional(readOnly = true) doesn't enforce immutability, but it does let Hibernate skip dirty checking and lets some drivers optimize the connection, which adds up on high-traffic query paths. Getting these three things right — proxy boundaries, propagation choice, and explicit rollback rules — resolves the overwhelming majority of "why didn't my transaction roll back" tickets before they're ever filed.