You wrote a Spring Boot application. You annotated a method with @Transactional. When an exception occurs, you expect the database transaction to roll back. Nothing changes. The data stays in the database.
This happens every day to developers new to Spring. It is also one of the best interview questions to test if someone actually understands how Spring transactions work.
Here is the full breakdown.
What @Transactional Is Supposed to Do
The @Transactional annotation tells Spring: wrap this method in a database transaction. If the method completes successfully, commit the changes. If an exception occurs, rollback the changes.
The typical use case looks like this:
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
paymentService.processPayment(order);
// If something goes wrong here, both order and payment should rollback
}
}
This works as expected if an exception occurs. Both the order and the payment rollback.
Why It Fails: Three Common Reasons
Reason 1: Self-Invocation
This is the most common cause. You call a @Transactional method from another method in the same class:
@Service
public class OrderService {
public void handleOrder(Order order) {
// Direct call, not through Spring proxy
createOrder(order);
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
}
Why does this not rollback? Because Spring transactions work through AOP proxies. When you call createOrder() from inside the same class, you are calling the method directly on this. The proxy is never involved. The transaction annotation is ignored.
The fix: Inject the service into itself or move the transactional method to another service:
@Service
public class OrderService {
@Autowired
private OrderService self;
public void handleOrder(Order order) {
// Call through proxy
self.createOrder(order);
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
}
Or better, move the transactional method to a separate service:
@Service
public class OrderService {
@Autowired
private OrderCreationService orderCreationService;
public void handleOrder(Order order) {
orderCreationService.createOrder(order);
}
}
@Service
public class OrderCreationService {
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
}
Reason 2: Checked Exception
By default, Spring only rolls back on unchecked exceptions (RuntimeException and Error). If you throw a checked exception, the transaction commits:
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) throws IOException {
orderRepository.save(order);
throw new IOException("File not found");
}
}
The IOException is a checked exception. The transaction commits. The order stays in the database.
The fix: Tell Spring which exceptions should trigger a rollback:
@Service
public class OrderService {
@Transactional(rollbackFor = IOException.class)
public void createOrder(Order order) throws IOException {
orderRepository.save(order);
throw new IOException("File not found");
}
}
Or rollback for all exceptions:
@Transactional(rollbackFor = Exception.class)
Reason 3: Non-Public Method
Spring AOP proxies only work on public methods. If you annotate a private, protected, or package-private method with @Transactional, it is ignored:
@Service
public class OrderService {
@Transactional
private void createOrder(Order order) {
orderRepository.save(order);
}
}
The annotation is silently ignored. No transaction is started. No rollback happens.
The fix: Make the method public.
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
The Full Picture: How Spring Transactions Work
Spring uses AOP (Aspect-Oriented Programming) to add transaction behavior. When you annotate a method with @Transactional, Spring creates a proxy around your bean.
The proxy does this:
- Starts a transaction before the method
- Executes your method
- Commits if no exception, rolls back if exception occurs
The key point: the proxy wraps calls from outside the bean. Calls from inside the bean bypass the proxy.
Why This Matters
Many developers work with legacy systems or poorly designed codebases. Self-invocation is common because developers add @Transactional to existing methods without refactoring the call chain.
If you inherit a codebase where transactions are not rolling back, check for these three issues in this order:
- Self-invocation (most likely)
- Checked exceptions
- Non-public methods
A Real Example
Imagine you are building an e-commerce application for a BD startup. A user places an order, payment fails, but the order is still created. Customers are charged but orders never ship. This happened because:
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
if (paymentFailed(order)) {
throw new PaymentFailedException("Payment declined");
}
}
public void checkout(CheckoutRequest request) {
// Self-invocation - transaction ignored
placeOrder(buildOrder(request));
}
}
The PaymentFailedException is a checked exception. The call is self-invocation. The transaction commits even on failure. The database is inconsistent.
The fix:
@Service
public class OrderService {
@Autowired
private OrderService self;
@Transactional(rollbackFor = PaymentFailedException.class)
public void placeOrder(Order order) {
orderRepository.save(order);
if (paymentFailed(order)) {
throw new PaymentFailedException("Payment declined");
}
}
public void checkout(CheckoutRequest request) {
self.placeOrder(buildOrder(request));
}
}
How to Test This
You can test transaction rollback behavior with @DataJpaTest:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class OrderServiceTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderService orderService;
@Test
public void testTransactionRollback() {
long initialCount = orderRepository.count();
try {
orderService.createOrderThatFails();
} catch (Exception e) {
// Expected
}
// Count should be same as initial - rollback occurred
assertEquals(initialCount, orderRepository.count());
}
}
If the count increased, the transaction did not rollback. Something is wrong with your @Transactional configuration.
Quick Reference
| Problem | Cause | Fix |
|---|---|---|
| Self-invocation | Call from same class | Inject self or move method to separate service |
| Checked exception | Default only rolls back on RuntimeException | Add rollbackFor = Exception.class
|
| Non-public method | Spring AOP only proxies public methods | Make method public |
| No @EnableTransactionManagement | Transaction management not enabled | Add @EnableTransactionManagement to config |
| Multiple transaction managers | Using wrong transaction manager | Specify @Transactional("transactionManagerName")
|
Interview Answer Template
If you get this question in an interview, structure your answer like this:
Explain the proxy mechanism: Spring uses AOP proxies to add transaction behavior. The proxy wraps calls from outside the bean.
Identify the most common cause: Self-invocation. Calling a
@Transactionalmethod from inside the same class bypasses the proxy.Explain checked vs unchecked: By default, Spring rolls back on unchecked exceptions only. Checked exceptions require
rollbackFor.Mention method visibility: Only public methods work with
@Transactionalbecause AOP proxies only wrap public methods.Show you know the fix: Inject the service into itself or move the method to a separate service. Use
rollbackForfor checked exceptions.
This shows the interviewer you understand Spring internals, not just annotations.
A Debugging Checklist
If your @Transactional is not rolling back, check these in order:
- Is the method public?
- Is the exception unchecked or did you specify
rollbackFor? - Are you calling the method from outside the bean or through
self? -
Is transaction management enabled? (
@EnableTransactionManagement) - Are you using the correct transaction manager?
- Is there a try-catch block swallowing the exception?
The most likely cause is #1, #2, or #3. Check those first.
Conclusion
The @Transactional annotation is powerful but not magic. It works through AOP proxies. If you bypass the proxy through self-invocation, the annotation is ignored. If you throw a checked exception, you need to tell Spring to rollback. If your method is not public, Spring cannot wrap it.
Understanding these three pitfalls will save you hours of debugging and data inconsistency issues in production.
Sources
- Spring Framework Documentation: Transaction Management
- Baeldung: Transactional with Spring
- Spring Boot Reference: Data Access
Top comments (1)
This is one of those bugs where the annotation gives a false sense of safety.
The important lesson is that transactions are not magic around a method. They are a runtime boundary with rules:
That last part is what makes these issues painful in production. The code reads like βall or nothing,β but the actual boundary may be somewhere else.
For teams building higher-level automation around database operations, this is exactly why mutating actions need dry runs, explicit approval gates, and audit logs. The happy path is not enough; you need to know what happens when the exception path is weird.
Good reminder that correctness lives in the boundary, not the decorator.