How does @Transactional actually work, and when does it silently do nothing?

Asked constantlyintermediate2–8 yrs10 min read

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.

  • @Transactional is 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 private and final methods — 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:

Compiled and run on this build
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"));
Output
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  placeAll

Read 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:

Compiled and run on this build
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);
Output
unchecked exception:
  BEGIN
    debited 5000
  ROLLBACK — unchecked: insufficient funds
checked exception:
  BEGIN
    debited 5000
  COMMIT  — checked: statement service unreachable

The 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.

ValueBehaviour
REQUIRED (default)Join the existing transaction, or start one
REQUIRES_NEWSuspend the current one, run in a genuinely separate transaction
NESTEDA savepoint inside the current transaction
SUPPORTSJoin if there is one, otherwise run with none
MANDATORYThrow if there is no existing transaction
NEVERThrow 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 interviewsReality
"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?

AnswerThe 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?

AnswerThe 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?

AnswerThe 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

TierExerciseTime
Warm-upWatch the proxy get bypassed5 min
ChallengeMake the annotation take effect20 min
ProductionThe refund that debited twice45 min
InterviewFull round replay10 min

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

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.