How Does @Transactional Propagation Work Across Spring Services?
The Problem: Two Services, One Transaction (Or So I Thought)
I had two Spring services. Service A calls Service B. Both methods had @Transactional. I expected them to share the same transaction.
They didn’t.
Service A starts transaction TX-001Service B starts transaction TX-002 <-- Why a new transaction?I checked the logs. No exception. No error. Just two separate transactions when I expected one.
What I Tried First
I added more annotations:
@Servicepublic class OrderService { @Autowired private PaymentService paymentService;
@Transactional public void placeOrder(Order order) { orderRepository.save(order); paymentService.processPayment(order); }}@Servicepublic class PaymentService { @Transactional public void processPayment(Order order) { paymentRepository.save(order.getPayment()); }}I ran the code. Both methods shared the same transaction. This worked!
But then I tried moving processPayment into the same class:
@Servicepublic class OrderService { @Transactional public void placeOrder(Order order) { orderRepository.save(order); processPayment(order); // Internal call }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void processPayment(Order order) { paymentRepository.save(order.getPayment()); }}I expected processPayment to start a new transaction. It didn’t. The REQUIRES_NEW was completely ignored.
Why This Happens: Spring AOP Proxies
Spring doesn’t apply transactions directly to your methods. It uses proxies.
When you inject a @Service, Spring injects a proxy object, not the actual instance. This proxy intercepts method calls and applies transaction logic.
Client Code | vTransaction Proxy (starts/commits transaction) | vYour Service MethodThe key insight: Only external calls go through the proxy.
When you call a method from within the same class:
placeOrder() | +--> processPayment() <-- Direct call, no proxy!The call never touches the proxy. No transaction intercept. Your @Transactional annotation on processPayment is meaningless.
Propagation Types Explained
Before diving deeper, let me clarify the propagation options:
| Propagation | Behavior |
|---|---|
| REQUIRED (default) | Join existing transaction or create new one |
| REQUIRES_NEW | Always create new transaction, suspend existing |
| NESTED | Create nested transaction (savepoint-based) |
| SUPPORTS | Join existing if present, otherwise non-transactional |
| NOT_SUPPORTED | Execute non-transactionally, suspend existing |
| MANDATORY | Must run within existing transaction (throws if none) |
| NEVER | Must not run within transaction (throws if one exists) |
For cross-service calls, REQUIRED (the default) means both methods share the same transaction. One rolls back, both roll back.
The Self-Invocation Trap
This is the most common mistake I see:
@Servicepublic class OrderService { @Transactional public void placeOrder(Order order) { orderRepository.save(order);
// This looks like it should start a new transaction // It won't. Direct method call bypasses the proxy. processPayment(order); }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void processPayment(Order order) { // REQUIRES_NEW is completely ignored here // Still running in placeOrder's transaction paymentRepository.save(order.getPayment()); }}I logged the transaction IDs:
placeOrder: Transaction ID = TX-100processPayment: Transaction ID = TX-100 <-- Same transaction!Both ran in the same transaction. My REQUIRES_NEW did nothing.
Other Common Pitfalls
Private Methods
@Transactionalprivate void internalMethod() { // Transaction annotation ignored // Proxies can't intercept private methods}Private methods never go through proxies. Spring can’t intercept them.
Final Classes or Methods
@Servicepublic final class FinalService { @Transactional public void doSomething() { // CGLIB can't subclass final classes }}Spring defaults to CGLIB proxies. Final classes and methods can’t be proxied.
How Cross-Service Calls Actually Work
When Service A injects Service B through Spring:
@Servicepublic class OrderService { @Autowired private PaymentService paymentService; // Injected proxy
@Transactional public void placeOrder(Order order) { orderRepository.save(order); // External call goes through proxy paymentService.processPayment(order); }}The call chain:
OrderService.placeOrder() | vPaymentService proxy | vTransaction interceptor (applies @Transactional) | vPaymentService.processPayment()The proxy intercepts the call. Transaction logic applies. Everything works.
Solutions That Actually Work
Solution 1: Separate Services (Recommended)
Extract the method to a different service:
@Servicepublic class OrderService { @Autowired private PaymentService paymentService;
@Transactional public void placeOrder(Order order) { orderRepository.save(order); paymentService.processPayment(order); // Goes through proxy }}@Servicepublic class PaymentService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void processPayment(Order order) { paymentRepository.save(order.getPayment()); }}This is clean, testable, and follows single responsibility.
Solution 2: Self-Injection
Inject the service into itself:
@Servicepublic class OrderService { @Autowired @Lazy // Avoid circular dependency issues private OrderService self;
@Transactional public void placeOrder(Order order) { orderRepository.save(order); self.processPayment(order); // Goes through proxy }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void processPayment(Order order) { paymentRepository.save(order.getPayment()); }}The @Lazy annotation prevents circular dependency errors during initialization.
Solution 3: AopContext
Use Spring’s AOP context:
@Servicepublic class OrderService { @Transactional public void placeOrder(Order order) { orderRepository.save(order); OrderService proxy = (OrderService) AopContext.currentProxy(); proxy.processPayment(order); }
@Transactional(propagation = Propagation.REQUIRES_NEW) public void processPayment(Order order) { paymentRepository.save(order.getPayment()); }}You also need to enable expose-proxy:
@EnableAspectJAutoProxy(exposeProxy = true)@SpringBootApplicationpublic class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); }}Proving Transaction Behavior With Logs
I added logging to verify:
@Aspect@Componentpublic class TransactionLoggingAspect { private static final Logger log = LoggerFactory.getLogger(TransactionLoggingAspect.class);
@Around("@annotation(org.springframework.transaction.annotation.Transactional)") public Object logTransaction(ProceedingJoinPoint pjp) throws Throwable { String txName = TransactionSynchronizationManager.getCurrentTransactionName(); boolean isNewTx = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Method: {}, Transaction: {}, Active: {}", pjp.getSignature().getName(), txName, isNewTx);
return pjp.proceed(); }}This revealed exactly what was happening in each method.
When to Use Each Propagation
| Scenario | Propagation |
|---|---|
| Default case - join existing or create new | REQUIRED |
| Audit logging that must commit even if main fails | REQUIRES_NEW |
| Optional operations that don’t need transaction | SUPPORTS |
| Batch operations with partial rollback support | NESTED |
Key Takeaways
- Proxy rule: Only external method calls go through proxies
- Self-invocation: Direct internal calls bypass all AOP magic
- Private methods: Never use
@Transactionalon private methods - Cross-service: Works correctly because Spring injects proxies
- When in doubt: Extract to a separate service
References
- Spring Framework Transaction Documentation
- Baeldung: Transaction Propagation
- Spring AOP Proxy Mechanism
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments