Spring Proxies Demystified: JDK Dynamic vs CGLIB — What Really Happens When You Add @Transactional

It was late on a Friday afternoon — the worst possible time for a production bug. A colleague called me over: “The transaction isn’t rolling back. I added @Transactional to the method but it’s still committing even when the exception is thrown.” He showed me the code. The annotation was there. The method was right. Everything looked correct.

The problem? He was calling the @Transactional method from within the same class. The proxy never got involved. The annotation was completely ignored at runtime.

That day made me realize: you can use Spring for years without truly understanding how annotations like @Transactional, @Cacheable, or @Async actually work. They feel like magic. But they’re not magic — they’re proxies. And once you understand what a proxy is, a whole category of confusing Spring bugs becomes instantly clear.

Let’s dig in.

The Foundation: Why Spring Needs Proxies

Spring’s core AOP (Aspect-Oriented Programming) mechanism — which powers @Transactional, @Cacheable, @Async, @Secured, and many others — works by intercepting method calls to inject cross-cutting behavior before and/or after your method runs.

To do this without modifying your source code, Spring wraps your bean in a proxy object. When something in the application context injects your service, it doesn’t inject your actual class — it injects a proxy that looks identical from the outside but intercepts calls to add behavior.

You write:                          Spring injects:

┌─────────────────────┐             ┌──────────────────────────────────────┐
│   OrderService      │             │   OrderService$$Proxy                │
│                     │             │   (wraps your real OrderService)     │
│  + placeOrder()     │   ──────►   │                                      │
│  + cancelOrder()    │             │  + placeOrder() {                    │
│                     │             │      // begin transaction             │
└─────────────────────┘             │      target.placeOrder();            │
                                    │      // commit or rollback           │
                                    │  }                                   │
                                    └──────────────────────────────────────┘

The caller sees the same interface/type.
The proxy intercepts the call and adds the transactional behavior.

Spring uses two completely different techniques to create these proxies depending on one key question: does your class implement an interface?

Proxy Type 1: JDK Dynamic Proxy (Interface-Based)

When your bean implements at least one interface, Spring’s default behavior (before Spring Boot 2.x) was to use the JDK Dynamic Proxy — a proxy mechanism built directly into the Java standard library since Java 1.3.

How It Works

The JDK proxy creates a new class at runtime that implements the same interfaces as your target. It delegates every method call through a single InvocationHandler, where Spring inserts its AOP advice chain.

// Your interface
public interface OrderService {
    void placeOrder(Order order);
    void cancelOrder(Long orderId);
}

// Your implementation
@Service
public class OrderServiceImpl implements OrderService {

    @Override
    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        inventoryService.reserve(order.getItems());
    }

    @Override
    public void cancelOrder(Long orderId) {
        orderRepository.deleteById(orderId);
    }
}

At startup, Spring detects the @Transactional annotation and creates a JDK Dynamic Proxy. Conceptually, this is what gets generated:

// What Spring generates at runtime (simplified — actual bytecode, not source):
public class $Proxy72 implements OrderService {

    private final InvocationHandler handler;       // Spring's AOP interceptor
    private final OrderServiceImpl target;         // your real bean

    @Override
    public void placeOrder(Order order) {
        // Spring's InvocationHandler intercepts this call
        handler.invoke(this, placeOrder_Method, new Object[]{ order });
        // Inside the handler:
        //   1. Check @Transactional → begin transaction
        //   2. Call target.placeOrder(order)  (your real code)
        //   3. No exception → commit
        //   4. RuntimeException → rollback
    }

    @Override
    public void cancelOrder(Long orderId) {
        // No @Transactional → handler just delegates directly
        handler.invoke(this, cancelOrder_Method, new Object[]{ orderId });
    }
}

The Full Interception Flow

Caller (e.g., Controller)
     │
     │  orderService.placeOrder(order)
     │  (orderService is actually the proxy — the caller doesn't know)
     ▼
┌─────────────────────────────────────────────────────┐
│  $Proxy72  (JDK Dynamic Proxy)                      │
│                                                     │
│  placeOrder(order) {                                │
│      handler.invoke(...)                            │
│  }                                                  │
└──────────────────────────┬──────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────┐
│  Spring AOP InvocationHandler                       │
│  (walks the interceptor chain)                      │
│                                                     │
│  1. TransactionInterceptor.before()                 │
│     → getConnection() from DataSource               │
│     → setAutoCommit(false)                          │
│     → bind transaction to current thread            │
└──────────────────────────┬──────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────┐
│  OrderServiceImpl  (your real bean)                 │
│                                                     │
│  placeOrder(order) {                                │
│      orderRepository.save(order);   ← uses TX      │
│      inventoryService.reserve(...); ← uses TX      │
│  }                                                  │
└──────────────────────────┬──────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────┐
│  TransactionInterceptor.after()                     │
│                                                     │
│  ├─ No exception? → connection.commit()             │
│  └─ RuntimeException? → connection.rollback()       │
└─────────────────────────────────────────────────────┘

The Critical Constraint

Because the proxy implements the interface, it can only intercept methods declared on that interface. If OrderServiceImpl has public methods not declared in OrderService, they will not be proxied. More importantly, the injection point must be typed to the interface:

// ✅ Works — injects the proxy (which is an OrderService)
@Autowired
private OrderService orderService;

// ❌ Fails at startup with BeanNotOfRequiredTypeException
// The proxy IS an OrderService, but is NOT an OrderServiceImpl
@Autowired
private OrderServiceImpl orderService;

Proxy Type 2: CGLIB Proxy (Subclass-Based)

Now what happens if your service doesn’t implement any interface? The JDK proxy mechanism simply can’t work — there’s no interface to implement. This is where CGLIB (Code Generation Library) comes in.

CGLIB takes a completely different approach: instead of implementing an interface, it generates a subclass of your concrete class at runtime. Every proxied method is overridden in the subclass to inject the interceptor chain, and then delegate up to super.

How It Works

// Your class — NO interface
@Service
public class ReportService {

    @Transactional(readOnly = true)
    public Report generateMonthlyReport(int month, int year) {
        return reportRepository.findByMonthAndYear(month, year);
    }

    public void scheduleReport(ReportRequest request) {
        schedulerRepository.save(request);
    }
}

CGLIB generates a subclass that Spring uses as the actual bean. Conceptually:

// What CGLIB generates at runtime (simplified):
public class ReportService$$EnhancerBySpringCGLIB$$a3f1c2 extends ReportService {

    private final MethodInterceptor interceptor;  // Spring's AOP chain

    @Override
    public Report generateMonthlyReport(int month, int year) {
        // CGLIB MethodInterceptor intercepts this call
        return (Report) interceptor.intercept(
            this, generateMonthlyReport_Method,
            new Object[]{ month, year },
            methodProxy
        );
        // Inside the interceptor:
        //   1. @Transactional(readOnly=true) → begin read-only transaction
        //   2. methodProxy.invokeSuper(...)  → calls your real code via super
        //   3. commit
    }

    @Override
    public void scheduleReport(ReportRequest request) {
        // No @Transactional, but CGLIB still overrides it
        // The interceptor checks → no advice to apply → just calls super directly
        interceptor.intercept(this, scheduleReport_Method,
            new Object[]{ request }, methodProxy);
    }
}

The Full Interception Flow

Caller
     │
     │  reportService.generateMonthlyReport(3, 2026)
     │  (reportService is the CGLIB subclass)
     ▼
┌──────────────────────────────────────────────────────────────────┐
│  ReportService$$EnhancerBySpringCGLIB$$a3f1c2  (CGLIB proxy)    │
│                                                                  │
│  generateMonthlyReport(3, 2026) {                                │
│      interceptor.intercept(...)                                  │
│  }                                                               │
└─────────────────────────────────┬────────────────────────────────┘
                                  │
                                  ▼
┌──────────────────────────────────────────────────────────────────┐
│  Spring AOP MethodInterceptor chain                              │
│                                                                  │
│  1. TransactionInterceptor.before()                              │
│     → begin read-only transaction                                │
└─────────────────────────────────┬────────────────────────────────┘
                                  │
                                  ▼
┌──────────────────────────────────────────────────────────────────┐
│  methodProxy.invokeSuper(obj, args)                              │
│  → calls ReportService.generateMonthlyReport()  (your code)     │
│  → runs inside the active transaction                            │
└─────────────────────────────────┬────────────────────────────────┘
                                  │
                                  ▼
┌──────────────────────────────────────────────────────────────────┐
│  TransactionInterceptor.after()                                  │
│  → commit (readOnly tx — no writes were expected)                │
└──────────────────────────────────────────────────────────────────┘

The Critical Constraints of CGLIB

Because CGLIB works by subclassing, it has strict requirements:

  • The class must not be final — a final class cannot be subclassed. Spring will throw an exception at startup.
  • Proxied methods must not be final — a final method cannot be overridden. CGLIB will silently skip it, meaning the annotation will be ignored.
  • Proxied methods must not be private — private methods are not inherited, so they cannot be overridden and intercepted.
  • A no-arg constructor must be accessible — CGLIB needs to instantiate the subclass. (Spring Boot 2.x relaxed this constraint using Objenesis, but it’s still good to be aware of.)
@Service
public final class PaymentService {    // ❌ final class — CGLIB will fail at startup!

    @Transactional
    public final void processPayment(Payment p) { ... }  // ❌ final method — proxy bypassed

    @Transactional
    private void validatePayment(Payment p) { ... }      // ❌ private — proxy bypassed

    @Transactional
    public void refundPayment(Payment p) { ... }         // ✅ normal public method — works
}

How Spring Decides Which Proxy to Use

The decision tree is straightforward, but it changed in Spring Boot 2.x:

Spring's proxy selection algorithm:

Does the bean class implement at least one interface?
     │
     ├─ YES
     │     │
     │     └─ Is proxyTargetClass = true?
     │              │
     │              ├─ YES → Use CGLIB (even though interface exists)
     │              │
     │              └─ NO  → Spring 5 / Boot 1.x: Use JDK Dynamic Proxy
     │                        Spring Boot 2.x+:    Use CGLIB (new default!)
     │
     └─ NO
          └─ Always use CGLIB (no choice — JDK proxy requires an interface)


Force CGLIB explicitly:
  @EnableTransactionManagement(proxyTargetClass = true)
  @EnableCaching(proxyTargetClass = true)
  spring.aop.proxy-target-class=true  (application.properties — Spring Boot default)

Yes, you read that right. Spring Boot 2.x changed the default to CGLIB, even when interfaces are present. The Spring team made this decision to avoid the injection pitfall I mentioned earlier — where injecting by concrete type fails with a JDK proxy. With CGLIB, @Autowired private OrderServiceImpl orderService works fine because the proxy is a subclass of OrderServiceImpl.

Side-by-Side Comparison

┌──────────────────────────┬─────────────────────────────┬─────────────────────────────┐
│                          │  JDK Dynamic Proxy          │  CGLIB Proxy                │
├──────────────────────────┼─────────────────────────────┼─────────────────────────────┤
│ Requires interface?      │ YES — mandatory             │ NO — works on any class     │
│ How it wraps the bean    │ Implements the interface    │ Extends the class           │
│ Proxy class name         │ $Proxy72 (or similar)       │ Service$$EnhancerByCGLIB$$  │
│ Can proxy private methods│ No                          │ No                          │
│ Can proxy final methods  │ N/A (not in interface)      │ No (silently ignored)       │
│ Works with final class   │ Yes (class not extended)    │ No (startup failure)        │
│ Inject by concrete type  │ No (BeanNotOfRequiredType)  │ Yes (subclass IS the type)  │
│ Performance              │ Slightly slower (reflection)│ Slightly faster (bytecode)  │
│ Spring Boot 2.x default  │ No (CGLIB is default now)   │ Yes                         │
│ Internal library         │ java.lang.reflect.Proxy     │ net.sf.cglib / Spring CGLIB │
└──────────────────────────┴─────────────────────────────┴─────────────────────────────┘

The Self-Invocation Trap — The Most Common Proxy Bug

This is the bug that ruined my colleague’s Friday afternoon — and it’s probably the single most common Spring proxy mistake I’ve encountered over the years.

When a method inside a bean calls another method on the same bean, the call goes directly to this — the real object — bypassing the proxy entirely. No AOP, no transaction, no cache. The annotation is completely ignored.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        sendConfirmationEmail(order);  // ⚠️ self-invocation!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        // You expect a NEW transaction here — but you won't get one.
        // This is called via "this.sendConfirmationEmail(...)"
        // The proxy is completely bypassed.
        emailRepository.logEmail(order);
    }
}

Here’s why. When the caller injects OrderService, they get the proxy. But inside placeOrder(), the call to sendConfirmationEmail() is literally this.sendConfirmationEmail(order) — and this is the real OrderService instance, not the proxy.

Caller → Proxy.placeOrder()
              │
              └─ TransactionInterceptor starts TX_1
              │
              └─ target.placeOrder()   ← enters your real object ("this")
                      │
                      └─ this.sendConfirmationEmail()
                              │
                              ⚠️ NO PROXY INVOLVED
                              ⚠️ REQUIRES_NEW is ignored
                              └─ runs inside TX_1 (the parent transaction)
                                 instead of a brand-new transaction

How to Fix Self-Invocation

There are a few solutions, ordered from most to least recommended:

Option 1 — Extract to a separate bean (best practice)

// Move sendConfirmationEmail to its own service
// Now it goes through a proxy when called from OrderService
@Service
public class OrderService {

    private final EmailService emailService;  // injected — goes through proxy

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        emailService.sendConfirmationEmail(order);  // ✅ proxy is involved
    }
}

@Service
public class EmailService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        // ✅ This NOW runs in a brand-new transaction
        emailRepository.logEmail(order);
    }
}

Option 2 — Self-inject the proxy (acceptable but smells)

@Service
public class OrderService {

    @Autowired
    private OrderService self;  // Spring injects the proxy, not "this"

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        self.sendConfirmationEmail(order);  // ✅ goes through proxy
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendConfirmationEmail(Order order) {
        emailRepository.logEmail(order);
    }
}

Option 3 — Use AopContext (explicit but couples to AOP internals)

// Requires @EnableAspectJAutoProxy(exposeProxy = true)
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    ((OrderService) AopContext.currentProxy()).sendConfirmationEmail(order);
}

A Complete Worked Example — Tracing @Transactional Step by Step

Let me walk through a complete example end-to-end so there’s no ambiguity about what happens at runtime.

// The service (no interface — Spring Boot default → CGLIB)
@Service
public class TransferService {

    private final AccountRepository accountRepository;

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to   = accountRepository.findById(toId).orElseThrow();

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException("Not enough balance");
        }

        from.debit(amount);
        to.credit(amount);

        accountRepository.save(from);
        accountRepository.save(to);
    }
}

At application startup, Spring processes the bean and sees @Transactional. Here’s what happens:

═══════════════════════════════════════════════════════════════════════
  STARTUP: Spring builds the ApplicationContext
═══════════════════════════════════════════════════════════════════════

1. Spring instantiates TransferService (the real object)
2. Spring detects @Transactional methods via BeanPostProcessor
3. CGLIB generates: TransferService$$EnhancerBySpringCGLIB$$xyz
4. The proxy wraps the real TransferService instance
5. The proxy is registered in the context as the "transferService" bean

═══════════════════════════════════════════════════════════════════════
  RUNTIME: A controller calls transferService.transfer(1L, 2L, 500)
═══════════════════════════════════════════════════════════════════════

Step 1: Controller calls the proxy (not the real TransferService)
        transferService.transfer(1L, 2L, new BigDecimal("500"))
             │
             ▼ CGLIB proxy intercepts

Step 2: Spring builds the interceptor chain for this method
        - Is @Transactional present? YES
        - TransactionInterceptor is added to the chain

Step 3: TransactionInterceptor.before()
        - Checks current thread for an active transaction
        - Finds none (default propagation = REQUIRED)
        - Opens a new connection from the DataSource
        - Sets autoCommit = false
        - Binds connection to TransactionSynchronizationManager (thread-local)

Step 4: methodProxy.invokeSuper() → calls your real transfer() method
        - accountRepository.findById(1L) → uses the bound connection
        - accountRepository.findById(2L) → uses the bound connection
        - from.debit(500) / to.credit(500)
        - accountRepository.save(from) → SQL UPDATE, uses bound connection
        - accountRepository.save(to)   → SQL UPDATE, uses bound connection
        - Method returns normally ✅

Step 5: TransactionInterceptor.after()
        - No exception → connection.commit()
        - Unbinds connection from thread-local
        - Returns connection to pool

═══════════════════════════════════════════════════════════════════════
  RUNTIME: transfer() throws InsufficientFundsException
═══════════════════════════════════════════════════════════════════════

Step 4 (variant):
        - InsufficientFundsException is thrown (extends RuntimeException)

Step 5 (variant): TransactionInterceptor.after()
        - Exception caught
        - Is it a RuntimeException? YES → connection.rollback()
        - Neither save() was committed — data is consistent ✅
        - Exception re-thrown to the caller

What the Proxy Actually Looks Like — Inspecting at Runtime

You can verify which proxy type Spring created by logging the actual class of an injected bean:

@Component
public class ProxyInspector implements ApplicationRunner {

    @Autowired
    private TransferService transferService;  // injected bean

    @Override
    public void run(ApplicationArguments args) {
        System.out.println("Bean class: " + transferService.getClass().getName());
        System.out.println("Superclass: " + transferService.getClass().getSuperclass().getName());
        System.out.println("Is CGLIB?   " + transferService.getClass().getName().contains("CGLIB"));
    }
}

// Output for a class WITHOUT interface (CGLIB):
// Bean class: com.example.TransferService$$EnhancerBySpringCGLIB$$3a7f2b1c
// Superclass: com.example.TransferService
// Is CGLIB?   true

// Output for a class WITH interface (JDK Proxy, when forced):
// Bean class: com.sun.proxy.$Proxy72
// Superclass: java.lang.reflect.Proxy
// Is CGLIB?   false

The Same Mechanism Powers @Cacheable and @Async

Once you understand that Spring AOP works through proxies, every annotation-based cross-cutting feature becomes predictable. @Cacheable works exactly the same way:

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
        // This will only be called if the result isn't already in the cache
    }
}

// Proxy intercepts findById(42L):
//
//  CacheInterceptor.before():
//    → Check cache "products" for key "42"
//    → MISS? → call real findById(42L) → put result in cache → return
//    → HIT?  → return cached value immediately (real method never called)
//
//  Same rules apply:
//  ❌ Self-invocation: this.findById(id) bypasses cache
//  ❌ Private method: proxy can't intercept it
//  ❌ Final class/method (CGLIB): silently ignored

Key Rules to Live By

  1. Never call an AOP-annotated method from within the same class. The proxy is bypassed. Extract to a separate bean if you need the behavior to apply.
  2. Never make proxied methods private or final. Spring won’t throw an error — it will just silently ignore the annotation. This is the worst kind of bug.
  3. Never make your bean class final if it needs to be proxied by CGLIB. Spring will fail at startup, which is at least easy to diagnose.
  4. In Spring Boot 2.x+, CGLIB is the default. You can inject beans by concrete type without issues. Don’t be surprised by the $$EnhancerBySpringCGLIB suffix in class names.
  5. The proxy is a sibling/child, not a wrapper around this. The real object has no idea it’s being proxied. There’s no way from inside the real object to access the proxy — unless you use AopContext.currentProxy() or self-injection.

Wrapping Up

Spring proxies are one of those framework internals that most developers interact with daily without thinking about — until something breaks in a non-obvious way. Understanding whether Spring is creating a JDK Dynamic Proxy or a CGLIB proxy, and knowing the constraints of each, will save you from a whole category of subtle bugs that are otherwise maddening to debug.

The mental model I keep in my head is simple: when you annotate a method with @Transactional, @Cacheable, or any other AOP annotation, Spring’s proxy intercepts the call only when it comes from outside the bean, through the injected reference. The moment you call a method on this inside the same class, you’ve escaped the proxy and the annotation is meaningless.

That’s not a bug. That’s how it’s designed. And once it clicks, you’ll read those annotations very differently — not as magic, but as a contract with the proxy that wraps your bean.

Have you run into a proxy-related Spring gotcha in production? Drop it in the comments — some of the best “aha” moments come from hearing how others hit the same wall.

1 thought on “Spring Proxies Demystified: JDK Dynamic vs CGLIB — What Really Happens When You Add @Transactional”

Leave a Comment

Your email address will not be published. Required fields are marked *