Skip to content

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.

Unexpected behavior
Service A starts transaction TX-001
Service 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:

OrderService.java
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
paymentService.processPayment(order);
}
}
PaymentService.java
@Service
public 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:

OrderService.java
@Service
public 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.

Proxy chain visualization
Client Code
|
v
Transaction Proxy (starts/commits transaction)
|
v
Your Service Method

The key insight: Only external calls go through the proxy.

When you call a method from within the same class:

Self-invocation bypass
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:

PropagationBehavior
REQUIRED (default)Join existing transaction or create new one
REQUIRES_NEWAlways create new transaction, suspend existing
NESTEDCreate nested transaction (savepoint-based)
SUPPORTSJoin existing if present, otherwise non-transactional
NOT_SUPPORTEDExecute non-transactionally, suspend existing
MANDATORYMust run within existing transaction (throws if none)
NEVERMust 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:

The bug that took me hours to find
@Service
public 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:

Transaction log
placeOrder: Transaction ID = TX-100
processPayment: Transaction ID = TX-100 <-- Same transaction!

Both ran in the same transaction. My REQUIRES_NEW did nothing.

Other Common Pitfalls

Private Methods

Wrong approach
@Transactional
private 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

Won't work with CGLIB
@Service
public 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:

Correct cross-service call
@Service
public 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:

Proxy chain for external call
OrderService.placeOrder()
|
v
PaymentService proxy
|
v
Transaction interceptor (applies @Transactional)
|
v
PaymentService.processPayment()

The proxy intercepts the call. Transaction logic applies. Everything works.

Solutions That Actually Work

Extract the method to a different service:

OrderService.java
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
paymentService.processPayment(order); // Goes through proxy
}
}
PaymentService.java
@Service
public 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:

Self-injection pattern
@Service
public 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:

AopContext approach
@Service
public 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:

Application configuration
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

Proving Transaction Behavior With Logs

I added logging to verify:

TransactionLoggingAspect.java
@Aspect
@Component
public 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

ScenarioPropagation
Default case - join existing or create newREQUIRED
Audit logging that must commit even if main failsREQUIRES_NEW
Optional operations that don’t need transactionSUPPORTS
Batch operations with partial rollback supportNESTED

Key Takeaways

  1. Proxy rule: Only external method calls go through proxies
  2. Self-invocation: Direct internal calls bypass all AOP magic
  3. Private methods: Never use @Transactional on private methods
  4. Cross-service: Works correctly because Spring injects proxies
  5. When in doubt: Extract to a separate service

References

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