What Really Happens When You Annotate @Transactional
by Eric Hanson, Backend Developer at Clean Systems Consulting
The proxy that makes @Transactional work
@Transactional works through Spring AOP. When Spring creates a bean that has @Transactional methods, it creates a proxy that wraps the bean. The proxy intercepts method calls, begins a transaction before the method executes, and commits or rolls back when the method returns.
Caller → Proxy → begin transaction → Target bean method → commit/rollback → return to caller
The proxy is generated by CGLIB (subclass proxy) or JDK dynamic proxy (interface proxy). For most Spring beans, CGLIB subclassing is used — the proxy is a subclass of your bean that overrides the transactional methods with transaction-management logic.
The self-invocation trap. Because @Transactional works through a proxy, calling a @Transactional method on this bypasses the proxy entirely:
@Service
public class OrderService {
public void processOrder(Order order) {
// Direct call on 'this' — bypasses the proxy
// @Transactional on saveOrder has NO EFFECT here
saveOrder(order);
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
auditRepository.save(new AuditEntry(order));
}
}
processOrder calls saveOrder directly on this — the proxy is not involved. saveOrder runs without a transaction. The @Transactional annotation is silently ignored.
The fix: inject the service into itself (Spring handles this without circular dependency issues in Spring 4.3+) or refactor to avoid self-invocation:
@Service
public class OrderService {
@Autowired
private OrderService self; // inject proxy, not 'this'
public void processOrder(Order order) {
self.saveOrder(order); // goes through proxy — @Transactional applies
}
@Transactional
public void saveOrder(Order order) { ... }
}
Or extract the transactional method to a separate bean — the cleaner architectural solution.
When the transaction starts and when it ends
@Transactional does not begin a transaction when the method is entered. It begins a connection when the first database operation executes within the transaction scope.
In practice this means:
@Transactional
public void processOrder(Order order) {
// No database connection acquired yet
PaymentResult payment = paymentGateway.charge(order.getTotal()); // no connection
Order saved = orderRepository.save(order); // connection acquired HERE
auditRepository.save(new AuditEntry(order)); // same connection
} // connection released HERE — commit or rollback
The connection is held from the first database operation to the end of the method. Any non-database work — HTTP calls, file I/O, computation — within the transaction scope holds the connection open during that work. As covered in the connection leaks article, external I/O inside @Transactional is a connection pool exhaustion source.
Propagation — what happens when transactions nest
Transaction propagation controls what happens when a @Transactional method is called from within an existing transaction. The six behaviors:
REQUIRED (default) — joins the existing transaction if one exists; creates a new one if not. The most common behavior. A @Transactional(propagation = REQUIRED) method called from within a transaction participates in that transaction — if the inner method throws, the outer transaction rolls back too.
REQUIRES_NEW — always creates a new transaction, suspending the current one. The new transaction commits or rolls back independently. Use for operations that must complete even if the outer transaction rolls back (audit logs, notification records):
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(AuditEntry entry) {
auditRepository.save(entry); // commits independently of caller's transaction
}
SUPPORTS — joins the existing transaction if one exists; runs non-transactionally if not.
NOT_SUPPORTED — runs non-transactionally, suspending any existing transaction.
MANDATORY — requires an existing transaction; throws IllegalTransactionStateException if none exists.
NEVER — must run without a transaction; throws if one exists.
For application code, REQUIRED and REQUIRES_NEW cover the significant cases. The others are useful for specific infrastructure scenarios.
The REQUIRED participation subtlety. When an inner method with REQUIRED joins the outer transaction, the inner method's exception handling interacts with the outer:
@Transactional
public void processOrder(Order order) {
try {
notificationService.sendEmail(order); // @Transactional(REQUIRED) — joins this transaction
} catch (EmailException e) {
log.warn("Email failed, continuing");
}
orderRepository.save(order);
}
@Transactional // REQUIRED — joins caller's transaction
public void sendEmail(Order order) {
throw new EmailException("SMTP unavailable");
// Because this participates in the outer transaction,
// throwing marks the transaction as rollback-only
}
When sendEmail throws, Spring marks the current transaction as rollback-only — even if the caller catches the exception and continues. When processOrder tries to commit, Spring finds the rollback-only mark and throws UnexpectedRollbackException. The orderRepository.save is also rolled back, even though the caller thought it handled the exception.
The fix: either let exceptions propagate and handle rollback correctly, or use REQUIRES_NEW for sendEmail to isolate the email failure from the order transaction.
Rollback rules — which exceptions trigger rollback
By default, @Transactional rolls back only for RuntimeException and its subclasses (unchecked exceptions). Checked exceptions do not trigger rollback by default:
@Transactional
public void processOrder(Order order) throws OrderProcessingException {
orderRepository.save(order);
throw new OrderProcessingException("processing failed");
// Checked exception — transaction COMMITS despite the exception
// The save persists even though an exception was thrown
}
This is the most surprising default behavior for developers coming from other frameworks. A checked exception thrown from a @Transactional method commits the transaction unless rollback is explicitly configured.
Configure rollback for specific exceptions:
@Transactional(rollbackFor = {OrderProcessingException.class, IOException.class})
public void processOrder(Order order) throws OrderProcessingException { ... }
// Roll back for all exceptions
@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception { ... }
Or configure no-rollback for specific runtime exceptions you expect and want to commit:
@Transactional(noRollbackFor = OptimisticLockingFailureException.class)
public void processOrder(Order order) {
// OptimisticLockingFailureException won't roll back — handle it at the caller
}
The practical rule: if your method throws checked exceptions that represent failure states that should not persist data, add rollbackFor. Most application code benefits from rollbackFor = Exception.class to make the behavior explicit and safe.
Read-only transactions
@Transactional(readOnly = true) is an optimization hint with two concrete effects:
- Hibernate disables dirty checking — no flush at transaction end, no scan of loaded entities for changes
- Spring sets a read-only flag on the JDBC connection — some databases and JDBC drivers can optimize for this (route to read replicas, skip undo log generation)
@Transactional(readOnly = true)
public List<OrderSummary> getOrderSummaries(String userId) {
// Hibernate won't track changes to entities loaded here
return orderRepository.findByUserId(userId);
}
Applying readOnly = true to all query methods is a meaningful optimization for JPA applications with heavy read traffic. The dirty checking pass at transaction end can be expensive when many entities are loaded and tracked.
readOnly = true does not prevent writes — Hibernate will write if you explicitly call save() or persist() within a read-only transaction. It's a hint, not enforcement. Using readOnly = true on methods that accidentally write will commit the writes normally.
Transaction timeout
@Transactional(timeout = 30) rolls back the transaction if it hasn't completed within 30 seconds:
@Transactional(timeout = 30)
public void processLargeOrder(Order order) {
// If this takes longer than 30 seconds, transaction is rolled back
// with TransactionTimedOutException
}
Transaction timeout is set when the transaction begins. It counts the total time from transaction start to commit, not just database time. For methods that involve I/O, computation, and database operations, the timeout must be generous enough to cover all of it under normal conditions.
Use transaction timeouts for operations that should fail fast if a lock wait runs long, or for batch operations with known upper bounds.
Testing @Transactional behavior
@Transactional in tests has a different behavior than in production: test transactions are rolled back after each test by default. Data changes made in a test are never committed — the database is clean for the next test:
@SpringBootTest
@Transactional // all test methods run in a rolled-back transaction
class OrderServiceIntegrationTest {
@Test
void processOrder_savesOrderAndAuditEntry() {
Order order = orderService.processOrder(new CreateOrderRequest(...));
assertThat(orderRepository.findById(order.getId())).isPresent();
assertThat(auditRepository.findByOrderId(order.getId())).isNotEmpty();
// After test: entire transaction rolls back — no cleanup needed
}
}
The test @Transactional is convenient but causes a specific false positive: tests that verify @Transactional(propagation = REQUIRES_NEW) behavior don't work correctly — the inner REQUIRES_NEW transaction joins the test's outer transaction rather than creating an independent one, because the test transaction has already begun.
For testing REQUIRES_NEW behavior, use @Commit on the test method or @Rollback(false) to allow the test to actually commit, then clean up in @AfterEach.
The annotation placement decision
@Transactional on the interface method vs the implementation method:
// Interface
public interface OrderService {
@Transactional // spring-tx will pick this up in some configurations
void processOrder(Order order);
}
// Implementation
@Service
public class OrderServiceImpl implements OrderService {
@Transactional // preferred — closer to the implementation
public void processOrder(Order order) { ... }
}
Spring recommends annotating the implementation class or its methods, not the interface. Proxy creation for interfaces (JDK dynamic proxy) may not honor @Transactional on interface methods correctly in all configurations. Annotating the concrete class is always correct.
For the same reason, @Transactional on private methods does nothing — the proxy can only intercept public and protected method calls. Spring logs a warning when it detects @Transactional on private methods in newer versions. If you need transaction management for logic that's currently private, extract it to a public method on a separate bean.