How does @Transactional actually work, and when does it silently do nothing?
Spring wraps your bean in a proxy that opens a transaction before the method and commits after. Everything surprising follows from that: a self-invoked call bypasses the proxy entirely, and a checked exception commits instead of rolling back.
The Answer
Say this in the room. 45 seconds.
@Transactionalis not a language feature and Spring does not rewrite your method. It wraps the bean in a proxy.- The proxy opens a transaction, calls your method, then commits — or rolls back if the method threw.
- So the annotation only takes effect on a call that goes through the proxy: an external call from another bean.
- Call an annotated method from another method of the same class and you bypass the proxy completely. The annotation does nothing, and nothing warns you.
- Same for
privateandfinalmethods — the proxy cannot intercept them. - Default rollback rule: unchecked exceptions roll back, checked exceptions commit. That surprises almost everyone.
Understand It
There is no transactional method, only a wrapped bean
When Spring sees @Transactional, it does not modify your class. At startup a post-processor replaces the bean in the container with a proxy that holds your real object inside it. Callers get the proxy; the proxy adds behaviour and delegates.
That is the entire mechanism, and every surprising behaviour is a consequence of it. You can build the same thing in twenty lines of plain Java — no Spring required:
OrderService service = transactional(new RealOrderService());
System.out.println("called from outside, through the proxy:");
service.placeOrder("A1");
System.out.println("placeAll, which loops and calls placeOrder itself:");
service.placeAll(List.of("B1", "B2"));called from outside, through the proxy:
BEGIN placeOrder
saving A1
COMMIT placeOrder
placeAll, which loops and calls placeOrder itself:
BEGIN placeAll
saving B1
saving B2
COMMIT placeAllRead the second half carefully. placeAll got its BEGIN and COMMIT, because it was called through the proxy. The two placeOrder calls inside it got nothing — no BEGIN, no COMMIT.
They were invoked on this, and this is the real object, not the proxy. The proxy is on the outside. Once you are inside the target, it is unreachable.
That is exactly what happens with @Transactional in Spring. This output is the answer to "why did my annotation do nothing?", and you can reproduce it without a database, a container, or the framework.
The three ways it silently does nothing
Self-invocation. The case above. this.method() never touches the proxy:
@Service
public class OrderService {
public void placeAll(List<String> ids) {
for (String id : ids) placeOrder(id); // no transaction, ever
}
@Transactional
public void placeOrder(String id) { ... }
}
Each placeOrder here runs with no transaction at all. The code reads as though every order is atomic. None of them are.
private methods. A JDK dynamic proxy implements an interface; a CGLIB proxy subclasses your class. Neither can intercept a private method, so the annotation is ignored.
final methods and final classes. CGLIB works by subclassing and overriding. A final method cannot be overridden, so it cannot be advised. With a final class Spring cannot create the proxy at all.
The common thread: if the call does not cross the proxy boundary, the annotation is inert. No exception, no warning, no log line. This is the single most common Spring bug that reaches production.
The rollback rule nobody expects
Spring's default is to roll back on RuntimeException and Error, and to commit on a checked exception:
Transfer transfer = withDefaultRollbackRules(checked -> {
System.out.println(" debited 5000");
if (checked) throw new java.io.IOException("statement service unreachable");
throw new IllegalStateException("insufficient funds");
});
System.out.println("unchecked exception:");
transfer.run(false);
System.out.println("checked exception:");
transfer.run(true);unchecked exception:
BEGIN
debited 5000
ROLLBACK — unchecked: insufficient funds
checked exception:
BEGIN
debited 5000
COMMIT — checked: statement service unreachableThe account was debited in both runs. In the second, the method threw, the caller saw an exception, and the debit was committed anyway.
The reasoning behind the default is defensible: a checked exception is part of the method's declared contract, so Spring treats it as an anticipated outcome rather than a failure. In practice almost nobody knows this, and it produces exactly the kind of partial write that shows up in a reconciliation report weeks later.
If you throw checked exceptions from transactional code, say so explicitly:
@Transactional(rollbackFor = Exception.class)
public void transfer(...) throws PaymentException { ... }
Propagation, in one paragraph you can actually use
propagation decides what happens when a transactional method is called while a transaction is already running.
| Value | Behaviour |
|---|---|
REQUIRED (default) | Join the existing transaction, or start one |
REQUIRES_NEW | Suspend the current one, run in a genuinely separate transaction |
NESTED | A savepoint inside the current transaction |
SUPPORTS | Join if there is one, otherwise run with none |
MANDATORY | Throw if there is no existing transaction |
NEVER | Throw if there is one |
The one worth understanding is REQUIRES_NEW, because it is the usual answer to "I need this audit row written even when the outer transaction rolls back". It genuinely suspends the outer transaction and uses a second connection — which means a pool of one will deadlock instantly, and it also means the outer transaction cannot see the inner one's writes until both commit.
REQUIRED is the default and is right almost always. Reaching for another value should have a reason you can state.
Why the whole method matters, not just the annotation
The transaction starts when the proxy is entered and ends when it returns. So the transaction is open for the entire method, including anything slow in it:
@Transactional
public void process(Order order) {
repository.save(order);
paymentGateway.charge(order); // a network call, inside the transaction
repository.markPaid(order);
}
That database connection is held for the duration of an external HTTP call. Under load the connection pool empties while every thread waits on someone else's network. The fix is to move the remote call outside the transactional boundary, which usually means splitting the method — and splitting it correctly means the two halves must be called from outside, or you are back to the self-invocation problem.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "How does @Transactional work?"
Spring replaces the bean with a proxy at startup. The proxy begins a transaction, delegates to your object, then commits or rolls back. Nothing about your class is modified, which is why every limitation is a proxy limitation.
2. "I annotated a method and it isn't transactional. Why?"
Almost certainly self-invocation — another method of the same class called it directly, so the call never crossed the proxy. Also possible: the method is private or final, or the class is final. All fail silently.
3. "How would you fix a self-invocation problem?"
Move the annotated method to another bean and inject it, which is usually the right design anyway. Alternatives: inject the bean into itself, or use AopContext.currentProxy() — both work and both are a smell that the class is doing two jobs.
4. "Which exceptions roll back by default?"
RuntimeException and Error. A checked exception commits. Override with rollbackFor. The rationale is that a checked exception is a declared outcome rather than a failure, but the practical result is silent partial writes.
5. "What does REQUIRES_NEW actually do?"
Suspends the current transaction and runs in a separate one on a second connection. That means a connection pool sized 1 deadlocks, and the inner transaction cannot see the outer's uncommitted writes.
6. "Should a transactional method make an HTTP call?" No. The transaction is open for the whole method, so the database connection is held across the network call and the pool drains under load. Move the remote call outside the boundary.
7. "Why does @Transactional on a private method not work, but the code still compiles?"
Because it is an annotation, not a language construct — nothing checks that it can be applied. The compiler has no idea Spring will try to proxy it. This is the general hazard of annotation-driven behaviour.
Code traps
Trap A — predict before you run:
@Service
public class ReportService {
@Transactional
public void generateAll(List<Long> ids) {
for (Long id : ids) generateOne(id);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(Long id) { ... }
}
Answer
There is exactly one transaction, from generateAll. REQUIRES_NEW never takes effect, because generateOne is self-invoked and the proxy is bypassed. The author's intent — each report isolated so one failure doesn't lose the others — is entirely absent, and the code looks correct. Move generateOne to a separate bean.
Trap B:
@Transactional
public void archive(Long id) {
try {
repository.delete(id);
} catch (Exception e) {
log.error("delete failed", e);
}
}
Answer
If delete throws and you swallow it, the transaction commits — the proxy never sees an exception, so it has no reason to roll back. Worse, if the exception marked the transaction rollback-only internally, the commit then fails with UnexpectedRollbackException at a point far from the cause. Catching inside a transactional method without rethrowing is how you get both silent data loss and a confusing error.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Spring rewrites the method to add transaction code." | It wraps the bean in a proxy. Your class is untouched. |
| "The annotation works wherever you put it." | Only on calls crossing the proxy. Self-invoked, private and final are inert. |
| "Any exception rolls back." | Checked exceptions commit unless you set rollbackFor. |
"REQUIRES_NEW just starts a nested transaction." | It suspends the outer one and takes a second connection. |
| "You get a warning if it can't apply." | Nothing warns you. That is why it reaches production. |
Check Yourself
Q1. In one sentence, why does a self-invoked @Transactional method do nothing?
Answer
The annotation is implemented by a proxy that wraps the bean, and an internal call runs on this — the real object — so it never crosses the proxy where the transaction would have been started.
Q2. A method debits an account then throws a checked exception. What is in the database?
Answer
The debit, committed. Spring's default rolls back on unchecked exceptions only; a checked exception is treated as a declared outcome and the transaction commits. rollbackFor = Exception.class changes it.
Q3. Why is calling a payment gateway inside a transactional method a problem?
Answer
The transaction — and therefore the database connection — is held for the whole method, including the network call. Under load every thread holds a connection while waiting on someone else's server, and the pool empties. Move the remote call outside the transactional boundary.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Watch the proxy get bypassed | 5 min |
| Challenge | Make the annotation take effect | 20 min |
| Production | The refund that debited twice | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Watch the proxy get bypassed
One concept, guided. Near-impossible to fail.
- Challenge20 min
Make the annotation take effect
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The refund that debited twice
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — @Transactional
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- How does Spring AOP work, and what are its limits?
- spring n plus one — not written yet
- sql isolation levels — not written yet
- spring circular dependencies — not written yet
Questions that lead here
How does Spring AOP work, and what are its limits?
Spring builds a proxy around your bean — a JDK dynamic proxy if it has an interface, a generated subclass otherwise — and the advice lives in the proxy, not in your class. Every limit follows: self-invoked, private, final and static methods never cross the proxy, so an annotation on them does nothing and nothing warns you.
Asked constantlyintermediate2–10 yrs11 min readSpring aop
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-27.