Overview
Spring's @Transactional is powered by AOP proxies: when you inject a @Service, you actually receive a CGLIB-generated subclass that wraps your bean. Every external method call goes through this proxy, which intercepts @Transactional methods and wraps them in a transaction. But when you call a @Transactional method from another method in the same class using 'this', you bypass the proxy entirely. The result is silent: no exception, no warning โ just your changes not being committed to the database.
Symptom
// No exception thrown โ changes silently not persisted class="hi-ann">@Service public class OrderService { public void processOrder(Order order) { validateOrder(order); // OK saveOrder(order); // โ ๏ธ NO TRANSACTION โ bypasses the proxy! } class="hi-ann">@Transactional public void saveOrder(Order order) { orderRepository.save(order); // Data lost if an exception occurs โ no rollback context exists } }
Root cause
AOP proxies only intercept method calls that originate from outside the bean. The call chain processOrder() โ saveOrder() never leaves the actual target object โ it calls this.saveOrder() directly, bypassing the CGLIB proxy that would have started the transaction. Spring has no hook into this internal call path.
Resolution
Extract the @Transactional method into a separate @Service bean โ the call from the original service now goes through the proxy.
Use TransactionTemplate for programmatic transaction control when you need transactional boundaries within a single class.
Self-inject the service via @Autowired and call through the self-reference โ ugly but effective as a last resort.
Refactor the class to ensure all transactional entry points are called from other Spring-managed beans.
Switch to AspectJ compile-time or load-time weaving for scenarios where proxy-based AOP is a genuine structural constraint.
// โ THE INVISIBLE BUG โ self-invocation skips the proxy class="hi-ann">@Service public class OrderService { public void processOrder(Order order) { saveOrder(order); // 'this.saveOrder()' โ proxy never sees this call } class="hi-ann">@Transactional public void saveOrder(Order order) { ... } // Transaction NEVER starts } // โ SOLUTION 1: Service Extraction (Recommended) class="hi-ann">@Service class="hi-ann">@RequiredArgsConstructor public class OrderService { private final OrderPersistenceService persistence; public void processOrder(Order order) { validateOrder(order); persistence.saveOrder(order); // Hits the proxy โ transaction starts โ } } class="hi-ann">@Service public class OrderPersistenceService { class="hi-ann">@Transactional public void saveOrder(Order order) { orderRepository.save(order); } } // โ SOLUTION 2: TransactionTemplate (Programmatic control) class="hi-ann">@Service class="hi-ann">@RequiredArgsConstructor public class OrderService { private final TransactionTemplate txTemplate; public void processOrder(Order order) { txTemplate.execute(status -> { orderRepository.save(order); return null; }); } }
Deep dive
How CGLIB Proxies Work
Spring uses CGLIB to subclass your class="hi-ann">@Service bean at startup. The generated subclass overrides each class="hi-ann">@Transactional method to inject transaction begin/commit/rollback logic around a super.methodName() call. When another bean calls your service, it calls the subclass's override. When your service calls itself internally, it calls the real this โ the superclass instance โ bypassing all overrides.
AspectJ Weaving: The Nuclear Option
AspectJ weaving injects transaction logic directly into your class's bytecode at compile time (CTW) or class-loading time (LTW). Self-invocation works perfectly with AspectJ because there's no proxy โ the instrumented method body itself contains the transaction setup. The tradeoff: a more complex build pipeline and load-time agent configuration. For most teams, Service Extraction is far simpler.
Detecting This Silently Failing Bug
Write integration tests using class="hi-ann">@DataJpaTest or class="hi-ann">@SpringBootTest that verify data is persisted after calling the outer (non-transactional) method. Mock-based unit tests will never catch this class of bug because they don't go through the Spring proxy infrastructure.
Best practices
Write at least one integration test per service class that verifies database state โ unit tests with mocks will never catch proxy-bypass issues.
Apply the Single Responsibility Principle rigorously: a method that orchestrates should not also be the transactional boundary.
Use @Transactional(readOnly = true) as the default on all service classes; override with @Transactional on write methods to make the contract explicit.
FAQ
- What causes @Transactional Self-Invocation Trap in Spring Boot 3?
- AOP proxies only intercept method calls that originate from outside the bean. The call chain processOrder() โ saveOrder() never leaves the actual target object โ it calls this.saveOrder() directly, bypassing the CGLIB proxy that would have started the transaction. Spring has no hook into this internal call path.
- How do I fix @Transactional Self-Invocation Trap?
- Extract the @Transactional method into a separate @Service bean โ the call from the original service now goes through the proxy. Use TransactionTemplate for programmatic transaction control when you need transactional boundaries within a single class. Self-inject the service via @Autowired and call through the self-reference โ ugly but effective as a last resort. Refactor the class to ensure all transactional entry points are called from other Spring-managed beans. Switch to AspectJ compile-time or load-time weaving for scenarios where proxy-based AOP is a genuine structural constraint.
- Best practice #1 for preventing Spring AOP ยท Transactions errors?
- Write at least one integration test per service class that verifies database state โ unit tests with mocks will never catch proxy-bypass issues.
- Best practice #2 for preventing Spring AOP ยท Transactions errors?
- Apply the Single Responsibility Principle rigorously: a method that orchestrates should not also be the transactional boundary.
- Best practice #3 for preventing Spring AOP ยท Transactions errors?
- Use @Transactional(readOnly = true) as the default on all service classes; override with @Transactional on write methods to make the contract explicit.