Technical Briefing
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.
โ Signal Detected
// 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 } }
โ Trace Analysis
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.
โฆ Remediation Plan
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; }); } }
โ Engineering 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.
โ Elite Standards
Engineering Rule
Write at least one integration test per service class that verifies database state โ unit tests with mocks will never catch proxy-bypass issues.
Engineering Rule
Apply the Single Responsibility Principle rigorously: a method that orchestrates should not also be the transactional boundary.
Engineering Rule
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.