Definition
- Checked exceptions -- by default,
@Transactionalrolls back only onRuntimeExceptionandError. A checked exception likeIOExceptionorPaymentGatewayExceptioncauses the proxy to COMMIT the transaction, potentially leaving half-finished data. - Self-invocation -- calling a
@Transactionalmethod from within the same class withthis.method()goes directly to the real object and bypasses the Spring proxy. No transaction is started. - Private methods -- Spring uses CGLIB to create a subclass proxy. A subclass cannot override a
privatemethod, so the@Transactionalannotation is silently ignored. - Swallowed exceptions -- if you catch an exception inside the method without re-throwing, the proxy sees a normal method return and COMMITS the partial work.
- Fix -- use
rollbackFor = Exception.class, extract methods to separate beans, make methods public, and always re-throw or callsetRollbackOnly().
At the National Bank of India, the vault door (proxy) only auto-closes on fire alarms (RuntimeException), not on smoke alarms (checked exceptions). If a teller asks himself to lock the vault (self-invocation), he just talks to himself and the vault mechanism never activates. The vault door only works on public entrances, not private back doors. And if a teller catches the fire alarm and silences it (swallowed exception), the vault door thinks everything is fine and stays open.
Understanding the Concepts
What are the four cases where @Transactional does not roll back?
Spring's @Transactional uses a proxy that intercepts method calls and manages the transaction lifecycle. The proxy starts a transaction before the method, commits on normal return, and rolls back on an exception. But there are four cases where this mechanism silently fails, causing data corruption without any error visible to the caller.
- The four cases are:
- Checked exceptions (commit by default)
- Self-invocation (proxy bypassed)
- Private methods (proxy cannot intercept)
- Swallowed exceptions (proxy sees normal return)
Proxy behavior:
Method returns normally -> COMMIT
Method throws RuntimeException -> ROLLBACK
Method throws checked exception -> COMMIT (surprise!)
Method called via this. -> NO PROXY, no transaction at all
Method is private -> NO PROXY, annotation ignored
Exception caught inside -> Method returns normally -> COMMIT
Why do checked exceptions cause a commit?
By default, Spring's transaction proxy rolls back only on unchecked exceptions (RuntimeException and its subclasses) and Error. Checked exceptions, which are exceptions that the method declares with throws, cause the proxy to COMMIT the transaction.
This design choice dates back to EJB conventions, where checked exceptions were considered "business exceptions" that the caller should handle, while runtime exceptions were considered unrecoverable failures.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class EMIPaymentService {
private final AccountRepository accountRepository;
private final PaymentGateway paymentGateway;
public EMIPaymentService(AccountRepository accountRepository,
PaymentGateway paymentGateway) {
this.accountRepository = accountRepository;
this.paymentGateway = paymentGateway;
}
// WRONG: checked exception causes COMMIT
@Transactional
public void processEMIWrong(String accountId, BigDecimal emiAmount)
throws PaymentGatewayException {
var account = accountRepository.findByIdForUpdate(accountId)
.orElseThrow();
account.setBalance(account.getBalance().subtract(emiAmount));
// Balance deducted in persistence context
// Gateway throws PaymentGatewayException (checked)
paymentGateway.charge(accountId, emiAmount);
// Exception propagates, but proxy sees CHECKED exception
// -> COMMITS! Balance deducted but payment never reached gateway.
}
// CORRECT: rollbackFor covers all exceptions
@Transactional(rollbackFor = Exception.class)
public void processEMICorrect(String accountId, BigDecimal emiAmount)
throws PaymentGatewayException {
var account = accountRepository.findByIdForUpdate(accountId)
.orElseThrow();
account.setBalance(account.getBalance().subtract(emiAmount));
paymentGateway.charge(accountId, emiAmount);
// If PaymentGatewayException is thrown:
// proxy sees rollbackFor = Exception.class -> ROLLBACK
// Balance deduction is undone. Data is consistent.
}
}
WRONG (default rollback rules):
EMI amount deducted from SAV2001: balance 500000 -> 490000
PaymentGatewayException thrown (gateway down)
Proxy sees checked exception -> COMMIT
SAV2001 balance = 490000, but payment never processed!
Customer charged but EMI not paid. Silent corruption.
CORRECT (rollbackFor = Exception.class):
EMI amount deducted from SAV2001: balance 500000 -> 490000
PaymentGatewayException thrown (gateway down)
Proxy sees rollbackFor matches -> ROLLBACK
SAV2001 balance = 500000 (unchanged). Data is consistent.
Why does self-invocation bypass the proxy?
Spring's @Transactional works through a proxy object. When another bean calls your service, the call goes through the proxy first, which starts the transaction. But when you call a method on this from within the same class, the call goes directly to the real object, bypassing the proxy entirely.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class BatchPaymentService {
private final AccountRepository accountRepository;
public BatchPaymentService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// WRONG: self-invocation bypasses proxy
public void processBatch(List<PaymentRequest> requests) {
for (PaymentRequest req : requests) {
// this.processSingle() bypasses the proxy!
// No transaction is started for each payment
this.processSingle(req);
}
}
@Transactional
public void processSingle(PaymentRequest req) {
var account = accountRepository.findByIdForUpdate(req.accountId())
.orElseThrow();
account.setBalance(account.getBalance().subtract(req.amount()));
}
}
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
// CORRECT: extract to a separate bean
@Service
public class BatchPaymentService {
private final SinglePaymentService singlePaymentService;
public BatchPaymentService(SinglePaymentService singlePaymentService) {
this.singlePaymentService = singlePaymentService;
}
public void processBatch(List<PaymentRequest> requests) {
for (PaymentRequest req : requests) {
// Call through injected bean -> proxy intercepts -> TX starts
singlePaymentService.processSingle(req);
}
}
}
@Service
class SinglePaymentService {
private final AccountRepository accountRepository;
SinglePaymentService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional
public void processSingle(PaymentRequest req) {
var account = accountRepository.findByIdForUpdate(req.accountId())
.orElseThrow();
account.setBalance(account.getBalance().subtract(req.amount()));
}
}
WRONG (self-invocation):
processBatch() -> this.processSingle()
Call goes: real object -> processSingle() (no proxy)
No transaction started. Changes may or may not persist
depending on auto-commit and flush timing.
CORRECT (separate bean):
processBatch() -> singlePaymentService.processSingle()
Call goes: proxy -> start TX -> processSingle() -> commit TX
Each payment runs in its own transaction.
Why are private methods ignored?
Spring Boot uses CGLIB to create proxy classes. CGLIB works by generating a subclass of your service class at runtime. The subclass overrides your methods to add transaction behavior. But Java does not allow a subclass to override a private method, so the @Transactional annotation on a private method is silently ignored.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// WRONG: @Transactional on private method -- silently ignored
@Transactional
private void debitAccount(String accountId, BigDecimal amount) {
var account = accountRepository.findByIdForUpdate(accountId)
.orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
}
// CORRECT: make the method public
@Transactional
public void debitAccountCorrect(String accountId, BigDecimal amount) {
var account = accountRepository.findByIdForUpdate(accountId)
.orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
}
}
WRONG (private):
CGLIB generates: class AccountService$$SpringCGLIB extends AccountService
Cannot override private debitAccount() -> annotation ignored
No transaction started. No error reported.
CORRECT (public):
CGLIB generates: class AccountService$$SpringCGLIB extends AccountService
Overrides public debitAccountCorrect() with TX advice
Transaction started, managed, and committed/rolled back correctly.
Why do swallowed exceptions cause a commit?
When you catch an exception inside a @Transactional method and do not re-throw it, the method returns normally to the proxy. The proxy sees a normal return and commits the transaction, even though the method encountered an error and may have left data in a partial state.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal;
import java.util.logging.Logger;
@Service
public class TransferService {
private static final Logger LOG = Logger.getLogger(TransferService.class.getName());
private final AccountRepository accountRepository;
public TransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// WRONG: swallow exception, proxy sees normal return -> COMMIT
@Transactional
public void transferWrong(String fromId, String toId, BigDecimal amount) {
try {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
to.setBalance(to.getBalance().add(amount));
} catch (Exception e) {
LOG.severe("Transfer failed: " + e.getMessage());
// WRONG: exception swallowed! Proxy sees normal return.
// Partial debit may have been flushed -> COMMITTED
}
}
// CORRECT option 1: re-throw the exception
@Transactional(rollbackFor = Exception.class)
public void transferCorrectRethrow(String fromId, String toId,
BigDecimal amount) {
try {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
to.setBalance(to.getBalance().add(amount));
} catch (Exception e) {
LOG.severe("Transfer failed: " + e.getMessage());
throw e; // Re-throw so proxy triggers rollback
}
}
// CORRECT option 2: mark rollback-only without re-throwing
@Transactional
public void transferCorrectSetRollback(String fromId, String toId,
BigDecimal amount) {
try {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
to.setBalance(to.getBalance().add(amount));
} catch (Exception e) {
LOG.severe("Transfer failed: " + e.getMessage());
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
// Proxy sees rollback-only flag -> ROLLBACK
}
}
}
WRONG (swallowed):
Debit SAV2001 succeeded (in persistence context)
Credit CUR3001 threw exception
Exception caught and logged, not re-thrown
Proxy sees normal return -> COMMIT
SAV2001 debited, CUR3001 NOT credited. Money lost!
CORRECT (re-throw):
Same scenario, but exception is re-thrown
Proxy catches RuntimeException -> ROLLBACK
SAV2001 and CUR3001 both unchanged. Consistent.
CORRECT (setRollbackOnly):
Same scenario, exception caught, setRollbackOnly() called
Proxy sees rollback-only flag -> ROLLBACK
Useful when you need to handle the error gracefully
but still want the transaction to roll back.
How do you prevent these pitfalls in a codebase?
Prevention is better than debugging. Use these defensive practices:
- Always use
rollbackFor = Exception.classon every@Transactionalannotation. - Run a static analysis rule or ArchUnit test that flags self-invocation of
@Transactionalmethods. - Code review rule: no
@Transactionalon private methods. - Code review rule: no
catchblocks inside@Transactionalmethods without re-throwing orsetRollbackOnly().
package com.bank.test;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
class TransactionalArchTest {
@Test
void transactionalMethodsMustBePublic() {
var classes = new ClassFileImporter()
.importPackages("com.bank");
ArchRule rule = methods()
.that().areAnnotatedWith(
org.springframework.transaction.annotation.Transactional.class)
.should().bePublic()
.because("CGLIB proxy cannot intercept non-public methods");
rule.check(classes);
}
}
ArchUnit test output (when a private @Transactional method exists):
java.lang.AssertionError: Architecture Violation
Rule: methods annotated with @Transactional should be public
because CGLIB proxy cannot intercept non-public methods
was violated (1 times):
Method AccountService.debitAccount() is not public
What should a senior engineer know?
- rollbackFor = Exception.class as a standard -- many teams enforce this as a project-wide standard. The EJB convention of treating checked exceptions as non-rollback is a historical artifact that causes more bugs than it prevents.
- AspectJ weaving as an alternative -- if you enable compile-time or load-time AspectJ weaving instead of CGLIB, self-invocation and private methods DO work because the weaving modifies the bytecode directly, not through a proxy subclass.
- @Transactional on interfaces -- if you place
@Transactionalon an interface method and use JDK dynamic proxies (not CGLIB), it works. But Spring Boot defaults to CGLIB, so place the annotation on the implementation class. - Testing for rollback -- use
@Transactionalon a test method to auto-rollback after each test. But be aware this can mask rollback bugs because the test itself forces a rollback.
package com.bank.config;
// To enable AspectJ weaving (fixes self-invocation and private methods):
// @EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
// Requires spring-aspects dependency and compile-time weaving plugin
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableTransactionManagement // default: mode = AdviceMode.PROXY (CGLIB)
public class TransactionConfig {
// Default CGLIB proxy mode:
// - Self-invocation: NOT intercepted
// - Private methods: NOT intercepted
//
// AspectJ mode:
// - Self-invocation: intercepted (bytecode is woven)
// - Private methods: intercepted
}
CGLIB proxy mode (default):
this.method() -> goes to real object -> NO proxy -> NO TX
private method -> cannot override -> NO proxy -> NO TX
AspectJ weaving mode:
this.method() -> bytecode is woven directly -> TX advice applied
private method -> bytecode is woven directly -> TX advice applied
Trade-off: requires compile-time plugin, harder to debug
What are common interview follow-up questions?
- Which pitfall is most dangerous? The checked-exception pitfall, because it causes silent data corruption. The transaction commits partial data, and no error is logged or returned to the caller.
- Can you combine rollbackFor and noRollbackFor? Yes.
@Transactional(rollbackFor = Exception.class, noRollbackFor = BusinessValidationException.class)rolls back on all exceptions except BusinessValidationException. - How do you detect self-invocation bugs? Use ArchUnit tests that check for calls to @Transactional methods from within the same class. Or enable AspectJ weaving to eliminate the problem entirely.
- What about Spring AOP vs AspectJ? Spring AOP uses runtime proxies (CGLIB or JDK). AspectJ uses compile-time or load-time bytecode weaving. AspectJ is more powerful but harder to configure.
Visual Overview
┌──────────────────────────────────────────────────────────┐ │ Normal @Transactional flow (proxy intercepts) │ │ │ │ Caller ──► Proxy ──► Real Object │ │ │ │ │ │ BEGIN TX execute method() │ │ │ │ │ │ │ normal return ──► COMMIT │ │ │ RuntimeException ──► ROLLBACK │ │ │ checked exception ──► COMMIT (!) │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Self-invocation (proxy BYPASSED) │ │ │ │ Caller ──► Proxy ──► Real Object │ │ │ │ │ this.otherMethod() │ │ │ │ │ Real Object.otherMethod() │ │ (no proxy, no TX) │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Fix: separate bean (proxy intercepts) │ │ │ │ Caller ──► Proxy-A ──► ServiceA │ │ │ │ │ otherService.method() │ │ │ │ │ Proxy-B ──► ServiceB.method() │ │ │ │ │ BEGIN TX execute method() COMMIT/ROLLBACK │ └──────────────────────────────────────────────────────────┘
Key Takeaway
There are four cases where @Transactional silently fails to roll back, and each one causes data corruption without any visible error. Checked exceptions commit by default because only RuntimeException and Error trigger rollback. Self-invocation bypasses the proxy because calling this.method() goes directly to the real object. Private methods are ignored because CGLIB cannot override them. Swallowed exceptions cause a commit because the proxy sees a normal method return.
In Rajesh's EMI payment service at the National Bank of India, the checked-exception pitfall was the most dangerous. A PaymentGatewayException (checked) caused the transaction to commit even though the payment gateway was down. The EMI was marked as processed in the database, but the payment never actually went through. The customer's balance was deducted with no payment made. Adding rollbackFor = Exception.class to every @Transactional annotation in the codebase became a standard practice.
Pankaj, the architect, enforces four defensive rules: always use rollbackFor = Exception.class, never call @Transactional methods from within the same class, never put @Transactional on private methods, and never catch exceptions inside a transactional method without re-throwing or calling setRollbackOnly(). ArchUnit tests enforce the public-method rule automatically.
Remember one sentence: @Transactional rolls back only on RuntimeException by default, so always add rollbackFor = Exception.class and never bypass the proxy.
---