Back to Multiple DataSources
Q2Question 2 of 2

@Transactional on interface vs implementation?

Key Concepts
  • Spring Boot defaults to CGLIB proxies (spring.aop.proxy-target-class=true) which create a subclass of the concrete class
  • CGLIB proxies read annotations from the target class, so @Transactional on the interface may be silently ignored
  • JDK dynamic proxies implement the interface and can read annotations placed there
  • Placing @Transactional on the implementation class works for both proxy types, making it the portable choice
  • AnnotationTransactionAttributeSource scans the target class first, so implementation annotations always win
  • Self-invocation (this.method()) bypasses the proxy entirely, so no transaction is started regardless of annotation placement

Definition

Spring Boot uses CGLIB proxies by default which read annotations from the concrete class, so placing @Transactional on the implementation is the only placement that works reliably for both CGLIB and JDK proxy types.
  • CGLIB proxy -- the Spring Boot default (spring.aop.proxy-target-class=true), creates a runtime subclass of the concrete class and reads @Transactional from the class hierarchy.
  • JDK dynamic proxy -- an interface-based proxy that implements the bean's interface and can read @Transactional from 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.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public interface TransferService {

    void transfer(String fromId, String toId, BigDecimal amount);
}
JAVA
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 |

JAVA
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);
}
JAVA
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!
    }
}
TEXT
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:

  1. Check the target method on the concrete class.
  2. Check the target class itself (class-level annotation).
  3. Check the interface method.
  4. Check the interface itself.

The implementation annotation always wins because it is checked first.

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

JAVA
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);
    }
}
TEXT
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.

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

JAVA
// 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.

JAVA
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());
    }
}
TEXT
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.

JAVA
// 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

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

---

Share
Use ← → arrow keys to navigate