I Spent Hours Debugging a Concurrency Bug That Should Not Have Existed
Two requests hit my service at the same time.
Both generated the same ID.
No errors. No warnings. Just wrong data.
The fix was literally one line.
Understanding why that fix worked, though, took much longer — and taught me a hard lesson about how @Transactional actually works in Spring.
The Problem
I had a service that generates unique order IDs. Here is the simplified version:
@Service
public class OrderService {
public Order createOrder(OrderRequest req) {
// The trap: calling the transactional method internally
String orderId = generateUniqueId();
// ... create order
}
@Transactional
public String generateUniqueId() {
var counter = counterRepo.findById("ORDER").orElseThrow();
counter.increment();
counterRepo.save(counter);
return "ORD-" + counter.getValue();
}
}
Two requests came in at the same time.
Both got the same order ID.
I assumed @Transactional would protect this (ACID, right?). It didn’t.
The Hard Truth
The real issue wasn’t the isolation level or database locking.
The transaction wasn’t even starting.
Spring didn't throw an error. It didn't warn me about a misused annotation. It just silently ignored it and ran the code without a transaction.
Why It Didn’t Work
Spring’s @Transactional (and AOP in general) works using proxies.
When a Spring-managed bean is called from the outside (like from a Controller), you aren't calling the raw object. You are calling a proxy that wraps your object. That proxy is responsible for the "magic"—starting the transaction, committing it, or rolling it back.
Controller → (Spring Proxy) → OrderService
↑
transaction starts here
But when you call a method from inside the same class, the call never goes through the proxy.
public Order createOrder(OrderRequest req) {
String orderId = generateUniqueId(); // This is a direct call on 'this'
}
This is self-invocation.
No proxy → No transaction → @Transactional is just a useless decoration here.
The Fix
Option 1: The "Clean" Way (Split the Class)
Move the transactional logic to a dedicated component. This forces the call to go through a fresh proxy.
@Service
public class IdGenerator {
@Transactional
public String generateOrderId() {
var counter = counterRepo.findById("ORDER").orElseThrow();
counter.increment();
counterRepo.save(counter);
return "ORD-" + counter.getValue();
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final IdGenerator idGenerator;
public Order createOrder(OrderRequest req) {
// Now calling an external bean, so the proxy intercepts it!
String orderId = idGenerator.generateOrderId();
// ...
}
}
Now the transaction starts correctly. No duplicate IDs. This is the approach I prefer—it respects Single Responsibility Principle.
Option 2: The "Self-Injection" Way (Legacy Code)
If you are stuck in a massive legacy class and can't refactor, you can inject the class into itself.
@Service
public class OrderService {
@Autowired
private OrderService self; // Inject the proxy of itself
public Order createOrder(OrderRequest req) {
// Call the method on the proxy, not 'this'
String orderId = self.generateUniqueId();
}
@Transactional
public String generateUniqueId() {
// ...
}
}
Does it work? Yes.
Does it look weird? Absolutely. Is it a circular dependency waiting to happen? Sometimes. Use with caution.
The Golden Rule
Annotations like @Transactional, @Async, @Cacheable, and @Retryable only work when:
- The method is called from outside the class.
- The call goes through a Spring-managed proxy.
- The method is
public(usually).
If you call this.myTransactionalMethod(), you are bypassing Spring entirely.
TL;DR
-
@Transactionalrelies on Spring proxies. - Internal method calls (
this.method()) skip the proxy. - No proxy = No transaction.
- Fix it by extracting the method to a new service or using self-injection.
Ever spent hours debugging something that turned out to be a "basics" issue? Would love to hear your "this should’ve been obvious" bugs in the comments! 👇
Top comments (0)