Why @Transactional Fails in Production
The Spring Proxy Mental Model Every Java Developer Should Understand
🚨 Production Brief
Every experienced Spring Boot developer has encountered at least one bug that looked impossible to explain.
The annotation was present.
The code compiled successfully.
The integration tests passed.
Yet production still ended up with:
Duplicate orders
Partial database updates
Missing audit records
Transactions that silently never started
The common assumption is that these are database problems. Most of the time, they are not.
They are the result of a single misunderstanding:
@Transactionalis not a database feature. It is a Spring Proxy feature.
Once you understand this distinction, self-invocation bugs, private method traps, and unexpected rollbacks all become easy to identify during code review instead of production.
Let's build that mental model.
❌ The Mental Model Most Developers Have
Ask any Spring Boot developer what @Transactional does, and the answer is usually something like this:
"It wraps all database operations in a single transaction. If something goes wrong, everything is rolled back."
That's not entirely wrong.But it's dangerously incomplete.
Most developers imagine @Transactional as an invisible wrapper around their method.
It's a simple and intuitive mental model.Unfortunately, Spring doesn't execute your code this way.
And that small difference is responsible for some of the most confusing production bugs you'll ever debug.
Before we look at the failures, let's understand what actually happens when a request reaches a @Transactional method.
🛡️ The Invisible Spring Proxy
Here's the most important idea in this entire article.
Spring never modifies your class.
When the application starts, Spring creates another object that stands in front of your service.
This object is called a Proxy.
Think of it as a security gate.
Every external request must pass through this gate before it reaches your actual business logic.
Only then does Spring:
Open a transaction
Execute your method
Decide whether to Commit or Rollback
Close the transaction
Visually, it looks like this.
Notice something important.
The transaction is not started by your OrderService.
It is started by the Spring Proxy that sits in front of it.
That's why experienced Spring developers don't ask,
"Does this method have
@Transactional?"
Instead, they ask,
"Will this call enter through Spring's Proxy?"
Because if the proxy is never reached,
the transaction is never created.
❌ The Self-Invocation Trap
Now let's revisit the diagram.
Every transaction begins only when the request enters through the Spring Proxy.
But what happens when one method inside the same class calls another method?
The flow changes completely.
Instead of entering through the proxy, the call goes directly from one method to another.
The proxy never gets a chance to intercept it.
At first glance, this looks perfectly normal.
But one detail changes everything.
The call to saveAuditLog() never entered through the Spring Proxy.
It went directly from one method to another inside the same object.
No proxy interception.
No transaction creation.
No warning.
No exception.
Just a silently ignored @Transactional annotation.
This is known as the Self-Invocation Trap, and it is one of the most common reasons developers believe @Transactional is "not working."
The annotation isn't broken.
The call simply never reached the component responsible for creating the transaction.
💻 A Production Code Review
Imagine you're reviewing the following pull request.
Nothing looks suspicious.
@Service
public class OrderService {
public void placeOrder() {
saveOrder();
saveAuditLog();
}
@Transactional
public void saveAuditLog() {
// Insert audit record
}
}
Most developers expect saveAuditLog() to execute inside its own transaction.
After all, the annotation is present.
The method is public.
The application starts successfully.
So where is the problem?
The answer becomes obvious once we trace the execution path.
External Request -> placeOrder() -> saveAuditLog()
Notice what is missing.
The call never passes through the Spring Proxy.
It is simply one method invoking another method on the same object.
From Spring's perspective, there was never an opportunity to intercept the call and create a transaction.
Adding @Transactional here is like installing a security gate on the main entrance and then entering the building through an internal hallway.
The gate exists.
It works perfectly.
But nobody ever walked through it.
That's why experienced Spring developers rarely look for annotations during code review.
They trace the execution path instead.
Because a transaction is created by how the method is called, not by where the annotation is written.
⚠️ Two More Silent Traps
The self-invocation problem is the most well-known @Transactional pitfall.
Unfortunately, it isn't the only one.
1. @Transactional on Private Methods
Many developers assume this should work.
@Transactional
private void saveAuditLog() {
}
It compiles.
It starts successfully.
No warnings appear in the logs.
Yet Spring completely ignores the annotation.
Why?
Because the Spring Proxy can only intercept methods that are visible from outside the bean.
A private method is an internal implementation detail.
The proxy can never invoke it, so no transaction is created.
2. Checked Exceptions Do Not Roll Back by Default
Now consider another common assumption.
"If an exception occurs, Spring will roll back the transaction."
That's only partially true.
By default, Spring rolls back for:
RuntimeException
Error
But a checked exception behaves differently.
@Transactional
public void processOrder() throws IOException {
saveOrder();
throw new IOException("Payment gateway unavailable");
}
Many developers expect the transaction to roll back.
Instead, Spring commits the transaction and then propagates the exception.
The application reports an error.
But the database already contains the new record.
At this point, a pattern starts to emerge.
@Transactional is not unpredictable.
It follows a very strict set of rules.
Most production bugs happen because developers assume those rules are different from what Spring actually implements.
Architecture Memory Card
Every time you review a Spring Boot service, don't ask:
"Does this method have
@Transactional?"
Instead, ask these four questions.
Production Checklist
1. Will this call enter through the Spring Proxy?
If No, the transaction will never start.
Examples:
Self-invocation
Direct internal method calls
Objects created using
new
2. Is the method visible to the Proxy?
If the method is private, Spring cannot intercept it.
No interception means no transaction.
3. What exception can this method throw?
By default:
✅ RuntimeException → Rollback
❌ Checked Exception → Commit
If your business logic throws checked exceptions, configure rollbackFor explicitly.
4. Am I thinking about an annotation or an execution path?
This is the most important question.
@Transactional is not a magic instruction attached to a method.
It is a behavior applied by a Spring Proxy only when the execution path passes through it.
Once you understand that mental model,
self-invocation bugs,
private method traps,
and unexpected commits stop being production mysteries.
They become architecture decisions.
📚 Spring Internals Series
Episode 01 → Why @Transactional Fails in Production ✅
Next Episode → Why Microservices Can't Use @Transactional (The Saga Pattern Explained)
Because once a business operation spans multiple services,
there is no single Spring Proxy left to save you.
