You put @Transactional on a service method, and suddenly the database treats everything inside it as one unit: either all of it sticks, or none of it does. Save three rows, throw an exception halfway through, and the first two vanish as if you never wrote them. You didn't open a transaction. You didn't commit at the end. You didn't roll back on the error. Yet all three happened.
So who did that work, and where does it run? That is what this article is about: the machinery Spring puts around your method the moment it sees @Transactional, the exact rule that decides whether it commits or rolls back — and the one call pattern that makes the whole thing silently do nothing.
We'll lean on one idea you've likely already met: the proxy, the stand-in object Spring slips in front of your bean so it can add behaviour around your methods without touching their bodies. Everything here is about what that stand-in actually does for transactions.
First, what a transaction even is
A transaction is a group of database operations that must succeed or fail together. Move money from one account to another and you touch two rows: subtract from one, add to the other. If the second write fails, the first must be undone too — otherwise money simply disappears. "All or nothing" is the whole promise.
The database gives you the tools to enforce that: a point where you begin, a point where you commit to make the changes permanent, and a rollback that throws away everything since the begin. Left alone, most databases auto-commit each statement on its own. A transaction is you telling the database, "stop doing that — hold these together until I say go."
The boilerplate you'd otherwise write by hand
Without Spring, wiring that up around some work looks like this:
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false); // begin: stop committing each statement
try {
// ... your inserts and updates ...
conn.commit(); // all good — make it permanent
} catch (Exception e) {
conn.rollback(); // something failed — undo everything
throw e;
} finally {
conn.close();
}
Read it once and the shape is obvious: turn off auto-commit to open a transaction, do the work, commit if you reach the end, roll back if anything throws, and always release the connection.
Now notice the problem. Your actual logic is the one commented line in the middle. Everything else is ceremony — and it would be identical in every method that needs to be atomic. A concern that repeats across many unrelated methods like this is a cross-cutting concern, and Spring's standing answer to those is: keep your method clean, and push the ceremony into a proxy.
@Transactional moves that ceremony into a proxy
Here's the same operation the Spring way. The method holds only the business logic:
@Service
public class Orders {
@Transactional
public void placeOrder(Order o) {
inventory.reserve(o);
payments.charge(o);
// no begin, no commit, no rollback — just the work
}
}
When Spring builds this bean, it doesn't hand callers the real Orders object. It hands them a proxy — a stand-in of the same type that sits in front of the real bean and can run extra code before and after each method. @Transactional is the marker that tells Spring, "this method needs the transaction ceremony wrapped around it."
The code that does the wrapping is a piece of advice living inside the proxy called the TransactionInterceptor. Every outside call to placeOrder lands in the interceptor first, and it runs — almost literally — the try/commit/catch/rollback block you saw above:
Object invoke(Method method, Object[] args) {
TransactionStatus tx = txManager.getTransaction(...); // begin
try {
Object result = method.invoke(target, args); // your method body
txManager.commit(tx); // commit on success
return result;
} catch (RuntimeException | Error ex) {
txManager.rollback(tx); // rollback on failure
throw ex;
}
}
That is the hand-written boilerplate again — except it was generated for you, and it wraps every @Transactional method without you repeating a line. Your method body is the method.invoke(target, args) in the middle; the interceptor supplies everything around it.
The transaction manager does the real database work
Notice the interceptor never touches a Connection itself. It delegates to a txManager — a PlatformTransactionManager, Spring's abstraction over "a thing that can begin, commit, and roll back a transaction."
Why an abstraction instead of raw JDBC? Because "a transaction" means different things to different persistence tools. Plain JDBC commits a Connection; JPA commits an EntityManager; each needs its own begin/commit calls. So Spring ships a different manager for each world — DataSourceTransactionManager for JDBC, JpaTransactionManager for JPA — and they all expose the same three methods. The interceptor calls getTransaction, commit, and rollback, and the right manager underneath translates that into the real thing: grabbing a connection, calling setAutoCommit(false), and so on.
How your queries join the same transaction
Here is the part that feels like magic until you see it. The manager opened a connection and began a transaction. But your method body calls a repository, a JdbcTemplate, or a JPA EntityManager — none of which you handed that connection to. How do they end up inside the same transaction instead of opening their own?
The answer is a thread-local handoff, run by a piece called the TransactionSynchronizationManager. When the transaction manager begins a transaction, it stashes the live connection in a map keyed to the current thread. When your repository later needs a connection, it doesn't call dataSource.getConnection() directly — it asks Spring's connection helper, which first checks that thread-local map and hands back the connection already bound there.
So the connection is shared not by passing it around, but by parking it on the thread everyone is running on. That single fact is why an entire call stack of repositories all land in one transaction without ever mentioning it.
It also explains a sharp edge: a transaction is tied to one thread. If your method spawns a new thread and does database work on it, that work runs on a different thread, finds nothing bound there, opens its own connection — and lives completely outside your transaction. The @Transactional boundary does not follow you across threads.
Gotcha 1: rollback only fires for unchecked exceptions
Look again at the interceptor's catch clause: catch (RuntimeException | Error). That is not a simplification — it is the actual default rule. Spring rolls back when your method throws an unchecked exception (a RuntimeException or an Error). If it throws a checked exception, Spring commits.
That surprises almost everyone the first time:
@Transactional
public void transfer() throws IOException {
debit(fromAccount);
throw new IOException("statement service down"); // checked → COMMITS the debit
}
The IOException is checked, so the interceptor doesn't treat it as a rollback signal. The debit is committed and the money is gone, even though the method failed. The reason is historical: the framework's authors decided checked exceptions represent "expected business outcomes" the caller might recover from, so they let the transaction stand.
You will usually disagree, and the override is one attribute:
@Transactional(rollbackFor = Exception.class) // roll back on any exception
Set rollbackFor whenever a checked exception should still undo the work. It's the most common @Transactional tuning there is.
Gotcha 2: swallow the exception and you lose the rollback
The interceptor can only react to an exception that actually escapes your method. If you catch it inside the method and don't rethrow, the proxy sees a clean return — and commits.
@Transactional
public void placeOrder(Order o) {
try {
risky(o);
} catch (Exception e) {
log.error("order failed", e); // swallowed — no exception leaves the method
}
// proxy sees a normal return → commits whatever risky() half-did
}
The transaction commits the partial work, because from the proxy's point of view nothing went wrong. If you must catch the exception but still want the work undone, mark the transaction for rollback explicitly:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
That flag tells the manager, "no matter how this method returns, roll back." The commit then turns into a rollback at the boundary.
Gotcha 3: self-invocation — the transaction never starts
This is the trap in the topic's title, and it falls straight out of how the proxy works: the proxy only wraps calls that arrive from outside the bean. A call the bean makes to itself never leaves the object, so it never crosses the proxy — and the interceptor never runs.
@Service
public class Orders {
public void placeAll(List<Order> orders) {
for (Order o : orders) {
save(o); // internal call: really this.save(o)
}
}
@Transactional
public void save(Order o) {
// expected to run in its own transaction
}
}
You'd expect each save to open and commit its own transaction. It opens none. When placeAll calls save, that is a plain this.save(o) running inside the real object, underneath the proxy. The proxy — and its TransactionInterceptor — is standing outside, and this call never reaches it. So save runs with no transaction at all, and, worse, there's no error to tell you.
Only a call to save from another bean, one that enters through the proxy, gets wrapped. The fixes all amount to making the call cross the proxy boundary: move save into a separate bean and inject it, inject the bean into itself and call through that reference, or reach for the current proxy with AopContext.currentProxy().
Gotcha 4: public and non-final only
One more limit inherited from the proxy. When Spring proxies by subclassing your class (its Boot default), it can only wrap methods it can override. A private method can't be overridden, and a final method can't be either — so @Transactional on either one quietly does nothing, with no warning. Keep transactional methods public and non-final and this never bites you.
The model to keep
@Transactional adds no code to your method. A proxy wraps the bean, and inside it the TransactionInterceptor runs the begin / commit / rollback ceremony around your call, delegating the real database work to a PlatformTransactionManager. The connection that manager opens is parked on the current thread, so every repository and query on that thread quietly joins the same transaction.
Two facts explain nearly every surprise. First, a rollback happens only when an unchecked exception escapes the method — checked exceptions commit, and a swallowed exception commits, unless you say otherwise. Second, the whole mechanism only fires when the call enters through the proxy from outside, so a self-invoked method gets no transaction at all. Hold those two facts and @Transactional stops being magic and becomes something you can reason about.
Top comments (0)