Definition
- CGLIB proxy -- the Spring Boot default (
spring.aop.proxy-target-class=true), creates a runtime subclass of the concrete class and reads@Transactionalfrom the class hierarchy. - JDK dynamic proxy -- an interface-based proxy that implements the bean's interface and can read
@Transactionalfrom interface methods. - AnnotationTransactionAttributeSource -- the component that scans for
@Transactional; it checks the target class first, so implementation annotations always take precedence. - Self-invocation pitfall -- calling
this.method()bypasses the proxy entirely, so no transaction is started regardless of where the annotation is placed.
At the National Bank of India, there is a job description (interface) and the actual employee (implementation). A visitor (proxy) asks the employee directly for a signature. If the signature policy is written only on the job description but the visitor looks at the employee's badge, the policy is missed. Placing the policy on the employee's badge (implementation) ensures it is always seen, regardless of whether the visitor checks the job description or the badge.
Understanding the Concepts
Why does Spring recommend @Transactional on the implementation?
Spring creates a proxy around every @Transactional bean. There are two proxy styles, and they read annotations from different places. Spring Boot defaults to CGLIB proxies, which create a runtime subclass of the concrete class. A CGLIB proxy scans the target class hierarchy for @Transactional. An annotation placed only on the interface may not be found by the CGLIB proxy, which means the transaction is silently skipped.
Placing @Transactional on the implementation class works for both proxy types, making it the safe, portable choice.
package com.bank.service;
import java.math.BigDecimal;
public interface TransferService {
void transfer(String fromId, String toId, BigDecimal amount);
}
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TransferServiceImpl implements TransferService {
private final AccountRepository accountRepo;
public TransferServiceImpl(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// CORRECT: annotation on the implementation
@Override
@Transactional(rollbackFor = Exception.class)
public void transfer(String fromId, String toId, BigDecimal amount) {
accountRepo.debit(fromId, amount);
accountRepo.credit(toId, amount);
}
}
How do CGLIB and JDK proxies differ?
The two proxy styles differ in how they are created and where they look for annotations.
| Aspect | JDK Dynamic Proxy | CGLIB Proxy |
|---|---|---|
| Mechanism | Implements the bean's interface | Subclasses the concrete class |
| Requires interface? | Yes | No |
| Reads interface annotations | Yes | May not |
| Reads class annotations | Yes (if delegated) | Yes |
| Spring Boot default | No | Yes (proxy-target-class=true) |
| Performance | Slightly faster creation | Slightly faster invocation |
package com.bank.service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
// Annotation on interface only -- risky with CGLIB
public interface AccountService {
@Transactional
void creditInterest(String accountId, BigDecimal interest);
}
package com.bank.service;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class AccountServiceImpl implements AccountService {
private final AccountRepository accountRepo;
public AccountServiceImpl(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Override
public void creditInterest(String accountId, BigDecimal interest) {
var account = accountRepo.findById(accountId).orElseThrow();
account.setBalance(account.getBalance().add(interest));
// With CGLIB proxy: @Transactional on interface may be
// silently IGNORED -- no transaction started!
}
}
CGLIB proxy behavior:
1. Spring creates AccountServiceImpl$$CGLIB subclass
2. Proxy scans AccountServiceImpl for @Transactional
3. NOT found on class -- checks interface? Depends on version
4. If not found: method runs WITHOUT a transaction
5. Dirty checking still happens but no commit/rollback boundary
JDK proxy behavior:
1. Spring creates a Proxy implementing AccountService
2. Proxy scans AccountService interface for @Transactional
3. FOUND on interface method -- transaction started
How does AnnotationTransactionAttributeSource resolve annotations?
Spring's AnnotationTransactionAttributeSource is the component that finds @Transactional attributes. It follows a specific lookup order:
- Check the target method on the concrete class.
- Check the target class itself (class-level annotation).
- Check the interface method.
- Check the interface itself.
The implementation annotation always wins because it is checked first.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class LoanServiceImpl implements LoanService {
// Method-level annotation on implementation -- always found
@Override
@Transactional(timeout = 10, rollbackFor = Exception.class)
public void approveLoan(String loanId, BigDecimal amount) {
// Transaction with 10-second timeout
}
}
What is the self-invocation pitfall?
Regardless of where @Transactional is placed, calling a method via this (self-invocation) bypasses the proxy entirely. The proxy only intercepts calls that come from outside the bean.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class PaymentServiceImpl {
private final AccountRepository accountRepo;
public PaymentServiceImpl(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
public void processPayment(String accountId, BigDecimal amount) {
// WRONG: self-invocation bypasses proxy -- no TX!
this.debitAccount(accountId, amount);
}
@Transactional
public void debitAccount(String accountId, BigDecimal amount) {
accountRepo.debit(accountId, amount);
}
}
External call:
paymentService.debitAccount("SAV2001", amount)
-> Proxy intercepts -> TransactionInterceptor -> TX started
Self-invocation:
this.debitAccount("SAV2001", amount)
-> Direct method call -> Proxy NOT involved -> NO TX
The fix is to inject the bean into itself or extract the transactional method into a separate bean.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class PaymentServiceFixed {
private final AccountRepository accountRepo;
private final PaymentTransactor transactor;
public PaymentServiceFixed(AccountRepository accountRepo,
PaymentTransactor transactor) {
this.accountRepo = accountRepo;
this.transactor = transactor;
}
public void processPayment(String accountId, BigDecimal amount) {
// CORRECT: call goes through proxy of separate bean
transactor.debitAccount(accountId, amount);
}
}
@Service
public class PaymentTransactor {
private final AccountRepository accountRepo;
public PaymentTransactor(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public void debitAccount(String accountId, BigDecimal amount) {
accountRepo.debit(accountId, amount);
}
}
What are common mistakes?
The most common mistake is placing @Transactional only on the interface and relying on CGLIB proxies to pick it up. In some Spring versions and configurations, this silently fails.
// WRONG: annotation only on interface
public interface InterestService {
@Transactional
void calculateDailyInterest(String accountId);
}
@Service
public class InterestServiceImpl implements InterestService {
@Override
public void calculateDailyInterest(String accountId) {
// May run WITHOUT a transaction under CGLIB proxy
}
}
// CORRECT: annotation on implementation
public interface InterestService {
void calculateDailyInterest(String accountId);
}
@Service
public class InterestServiceImpl implements InterestService {
@Override
@Transactional
public void calculateDailyInterest(String accountId) {
// Always runs with a transaction
}
}
How do you verify which proxy type is being used?
You can check the proxy type at runtime by inspecting the bean's class name. A CGLIB proxy has $$EnhancerBySpringCGLIB$$ in the name, while a JDK proxy shows $Proxy.
package com.bank.debug;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
@Component
public class ProxyTypeChecker implements CommandLineRunner {
private final ApplicationContext ctx;
public ProxyTypeChecker(ApplicationContext ctx) {
this.ctx = ctx;
}
@Override
public void run(String... args) {
Object bean = ctx.getBean("transferServiceImpl");
System.out.println("Proxy class: " + bean.getClass().getName());
}
}
CGLIB proxy output:
Proxy class: com.bank.service.TransferServiceImpl$$EnhancerBySpringCGLIB$$abc123
JDK proxy output:
Proxy class: com.sun.proxy.$Proxy45
What should a senior engineer know?
A senior engineer knows that Spring Framework 6 and Spring Boot 3 enforce CGLIB-based proxies by default and have improved annotation scanning to read interface annotations more reliably. However, placing @Transactional on the implementation remains the documented recommendation because it is forward-compatible and works across all Spring versions.
Senior engineers also know that @Transactional on a private or final method is silently ignored because CGLIB cannot subclass private or final methods. The compiler gives no warning. Use a linting tool like ArchUnit or SonarQube to catch this.
// WRONG: private method -- CGLIB cannot proxy it
@Transactional
private void internalTransfer(String fromId, String toId,
BigDecimal amount) {
// Transaction annotation silently ignored
}
// WRONG: final method -- CGLIB cannot override it
@Transactional
public final void finalTransfer(String fromId, String toId,
BigDecimal amount) {
// Transaction annotation silently ignored
}
// CORRECT: public, non-final method
@Transactional
public void transfer(String fromId, String toId,
BigDecimal amount) {
// Transaction applied correctly
}
What are common interview follow-up questions?
- Can you force Spring Boot to use JDK dynamic proxies instead of CGLIB?
- What happens if @Transactional is on both the interface and the implementation with different attributes?
- Does @Transactional on a private method produce a compile error or a runtime error?
- How does self-invocation bypass the proxy, and what are the fixes?
- What tools can detect misplaced @Transactional annotations at build time?
Visual Overview
JDK Dynamic Proxy:
┌─────────────────────┐ ┌──────────────────────┐
│ TransferService │ │ TransferServiceImpl │
│ (interface) │ │ (concrete class) │
│ @Transactional ✓ │◀────│ │
└─────────────────────┘ └──────────────────────┘
│
▼
JDK Proxy implements interface
-> reads @Transactional from interface ✓
CGLIB Proxy (Spring Boot default):
┌─────────────────────┐ ┌──────────────────────┐
│ TransferService │ │ TransferServiceImpl │
│ (interface) │ │ (concrete class) │
│ @Transactional ? │ │ @Transactional ✓ │
└─────────────────────┘ └──────────┬───────────┘
│
▼
CGLIB Proxy extends class
-> reads @Transactional from class ✓
-> may MISS interface annotation ✗
Safe placement: always on implementation
Key Takeaway
Spring Boot uses CGLIB proxies by default, and CGLIB proxies create a subclass of the concrete class. They read @Transactional from the class hierarchy, not necessarily from the interface. Placing the annotation only on the interface means the CGLIB proxy may silently skip it, and the method runs without a transaction. Placing it on the implementation works for both CGLIB and JDK proxies, which is why Spring's documentation recommends it.
In the National Bank of India transfer service, Rajesh initially placed @Transactional on the TransferService interface. The application ran fine in development, but under Spring Boot's default CGLIB proxy the annotation was silently ignored. Transfers ran without a transaction boundary, and a partial failure left accounts inconsistent. Moving the annotation to TransferServiceImpl fixed the problem immediately.
A senior engineer also watches for two silent killers: self-invocation (calling this.method() bypasses the proxy entirely) and @Transactional on private or final methods (CGLIB cannot subclass them). Both fail silently with no compile-time or runtime error. Use SonarQube or ArchUnit to catch these at build time.
Remember one sentence: always put @Transactional on the implementation class, because CGLIB proxies read from the class, not the interface.
---