How Spring Proxies Work and the Self-Invocation Trap

Introduction

This tutorial looks inside the proxies through which Spring applies aspects, transactions, caching, async execution and retries. We will build both kinds of proxy that Spring uses - a JDK dynamic proxy and a CGLIB subclass - first by hand, then see which one Spring Boot creates for a bean and what changes when the setting spring.aop.proxy-target-class is switched. Then comes the most common proxy bug: the self-invocation trap, where a method calls a @Transactional method of its own class, the transaction silently never starts, and money disappears from an account. We will fix it three ways, and finish with the trap of final methods. Every output below comes from running the code.

Why Spring needs proxies

In Spring AOP ExplainedClick to read tutorial: Spring AOP Explained: Aspects, Advice and Pointcuts, an aspect ran before the methods of OrderService, although OrderService contains no call to it. Java has no built-in way to add code to a method of an existing class at runtime. What Spring can do is control which object other beans receive: instead of the bean itself, it hands out a proxy - an object of the same type that receives every call, runs the advice, and passes the call on to the real bean, the target. Spring's annotations such as @Transactional, @Cacheable, @Async and @Retryable work the same way: each is advice applied by a proxy. Java offers two ways to create such an object at runtime, and Spring uses both -
  • JDK dynamic proxy: java.lang.reflect.Proxy creates a new class that implements the interfaces of the target and sends every call to an InvocationHandler. Part of the JDK, but it works only through interfaces: the proxy is not an instance of the target's class.
  • CGLIB proxy: Spring's built-in copy of the CGLIB library generates a subclass of the target class at runtime, overriding its methods. The proxy is an instance of the target's class, so it can be injected by class, but final classes and methods cannot be overridden.
A JDK dynamic proxy implements the bean's interfaces and is not an instance of its class; a CGLIB proxy is a generated subclass of the bean's class; both pass calls on to the real target bean

Both kinds of proxy, made by hand

A payment gateway, with an interface and an implementation, is the target -
src/main/java/.../kinds/PaymentGateway.java
//PaymentGateway.java
package com.javabrahman.aop.proxies.kinds;

public interface PaymentGateway {
  String pay(String customer, int amount);
}
src/main/java/.../kinds/CardPayments.java
//CardPayments.java
package com.javabrahman.aop.proxies.kinds;

import org.springframework.stereotype.Service;

@Service
public class CardPayments implements PaymentGateway {

  @Override
  public String pay(String customer, int amount) {
    System.out.println("      CardPayments.pay() runs");
    return "paid " + amount + " for " + customer;
  }
}
The program below wraps it twice, without a Spring context. A JDK proxy comes from Proxy.newProxyInstance() with a lambda as the InvocationHandler. The CGLIB proxy comes from Spring's ProxyFactory, the class that Spring's own auto-proxying uses, with setProxyTargetClass(true) and a MethodInterceptor as the advice -
src/main/java/.../kinds/HandMadeProxies.java
//HandMadeProxies.java
package com.javabrahman.aop.proxies.kinds;

import java.lang.reflect.Proxy;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;

public class HandMadeProxies {

  public static void main(String[] args) {
    CardPayments target = new CardPayments();

    // 1. A JDK dynamic proxy: implements PaymentGateway, sends every call to the handler
    PaymentGateway jdkProxy = (PaymentGateway) Proxy.newProxyInstance(
        PaymentGateway.class.getClassLoader(),
        new Class<?>[] {PaymentGateway.class},
        (proxy, method, methodArgs) -> {
          System.out.println("   [jdk handler] before " + method.getName());
          Object result = method.invoke(target, methodArgs);   // pass the call on to the target
          System.out.println("   [jdk handler] after " + method.getName());
          return result;
        });
    System.out.println("JDK proxy class:   " + jdkProxy.getClass().getName());
    System.out.println("instance of PaymentGateway? " + (jdkProxy instanceof PaymentGateway)
        + ", of CardPayments? " + (jdkProxy instanceof CardPayments));
    System.out.println(jdkProxy.pay("Ann", 900));

    // 2. A CGLIB proxy: a generated subclass of CardPayments
    ProxyFactory factory = new ProxyFactory(target);
    factory.setProxyTargetClass(true);
    factory.addAdvice((MethodInterceptor) invocation -> {
      System.out.println("   [interceptor] before " + invocation.getMethod().getName());
      Object result = invocation.proceed();
      System.out.println("   [interceptor] after " + invocation.getMethod().getName());
      return result;
    });
    CardPayments cglibProxy = (CardPayments) factory.getProxy();
    System.out.println();
    System.out.println("CGLIB proxy class: " + cglibProxy.getClass().getName());
    System.out.println("its superclass:    " + cglibProxy.getClass().getSuperclass().getName());
    System.out.println(cglibProxy.pay("Raj", 450));
  }
}
 OUTPUT of the above code
JDK proxy class:   jdk.proxy1.$Proxy0
instance of PaymentGateway? true, of CardPayments? false
   [jdk handler] before pay
      CardPayments.pay() runs
   [jdk handler] after pay
paid 900 for Ann

CGLIB proxy class: com.javabrahman.aop.proxies.kinds.CardPayments$$SpringCGLIB$$0
its superclass:    com.javabrahman.aop.proxies.kinds.CardPayments
   [interceptor] before pay
      CardPayments.pay() runs
   [interceptor] after pay
paid 450 for Raj
  • The JDK proxy's class, jdk.proxy...$Proxy..., was generated by the JVM. It implements PaymentGateway, but it is not a CardPayments: a variable of type CardPayments cannot hold it.
  • The CGLIB proxy's class, CardPayments$$SpringCGLIB$$0, extends CardPayments, so the cast to CardPayments works.
  • In both cases the extra code ran around the real method, which ran on the target object. This is all there is to Spring AOP: the advice of an aspect becomes an interceptor in the proxy's chain.

Which proxy Spring Boot creates

Spring Framework on its own creates a JDK proxy whenever the bean implements an interface, and a CGLIB proxy otherwise. Spring Boot changes that default: it sets spring.aop.proxy-target-class=true, so beans get CGLIB proxies even when they implement interfaces. The reason is the error that the second run below shows. This application advises CardPayments with an aspect, and leaves ReceiptPrinter without advice -
src/main/java/.../kinds/ReceiptPrinter.java - a bean without advice
//ReceiptPrinter.java
package com.javabrahman.aop.proxies.kinds;

import org.springframework.stereotype.Component;

@Component
public class ReceiptPrinter {

  public String print(String payment) {
    return "RECEIPT: " + payment;
  }
}
src/main/java/.../kinds/PaymentLogAspect.java
//PaymentLogAspect.java
package com.javabrahman.aop.proxies.kinds;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class PaymentLogAspect {

  @Before("execution(* com.javabrahman.aop.proxies.kinds.CardPayments.pay(..))")
  public void log() {
    System.out.println("   [aspect] a payment starts");
  }
}
src/main/java/.../kinds/ProxyKindsApp.java
//ProxyKindsApp.java
package com.javabrahman.aop.proxies.kinds;

import org.springframework.aop.support.AopUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

@SpringBootApplication
public class ProxyKindsApp {

  public static void main(String[] args) {
    try (ConfigurableApplicationContext context = SpringApplication.run(ProxyKindsApp.class, args)) {
      PaymentGateway gateway = context.getBean(PaymentGateway.class);
      System.out.println("paymentGateway bean: " + gateway.getClass().getName());
      System.out.println("  JDK proxy? " + AopUtils.isJdkDynamicProxy(gateway)
          + ", CGLIB proxy? " + AopUtils.isCglibProxy(gateway));
      System.out.println("receiptPrinter bean: " + context.getBean(ReceiptPrinter.class).getClass().getName());
      System.out.println(gateway.pay("Ann", 900));

      try {
        context.getBean(CardPayments.class);
        System.out.println("getBean(CardPayments.class) works");
      } catch (RuntimeException e) {
        System.out.println("getBean(CardPayments.class): " + e.getClass().getSimpleName() + "\n  " + e.getMessage());
      }
    }
  }
}
First with Spring Boot's defaults, then with --spring.aop.proxy-target-class=false -  OUTPUT with Spring Boot's default (CGLIB proxies)
paymentGateway bean: com.javabrahman.aop.proxies.kinds.CardPayments$$SpringCGLIB$$0
  JDK proxy? false, CGLIB proxy? true
receiptPrinter bean: com.javabrahman.aop.proxies.kinds.ReceiptPrinter
   [aspect] a payment starts
      CardPayments.pay() runs
paid 900 for Ann
getBean(CardPayments.class) works
 OUTPUT with spring.aop.proxy-target-class=false (JDK proxies)
paymentGateway bean: jdk.proxy2.$Proxy47
  JDK proxy? true, CGLIB proxy? false
receiptPrinter bean: com.javabrahman.aop.proxies.kinds.ReceiptPrinter
   [aspect] a payment starts
      CardPayments.pay() runs
paid 900 for Ann
getBean(CardPayments.class): NoSuchBeanDefinitionException
  No qualifying bean of type 'com.javabrahman.aop.proxies.kinds.CardPayments' available
  • Only the advised bean is a proxy. ReceiptPrinter, which no pointcut matches, is the plain class: Spring creates a proxy only when some advice applies to a bean.
  • With JDK proxies, the bean in the context implements PaymentGateway but is no longer a CardPayments, and looking it up, or injecting it, by its class fails. Code that injects implementation classes - common in real projects - breaks as soon as an aspect, a @Transactional or a @Cacheable is added to the class. Spring Boot's CGLIB default avoids exactly this.

The self-invocation trap

A proxy can only act on calls that go through it. When a method of a bean calls another method of the same bean, the call goes to this - the target object - and the proxy never sees it. No advice runs: no transaction, no cache, no async thread, no retry. A call from another bean goes through the proxy and gets a transaction; a call from inside the bean to this.transfer() goes straight to the target and runs without one Here is what this means for a transaction. Two accounts live in an H2 database -
src/main/resources/schema.sql
create table account (name varchar(20) primary key, balance int not null);
insert into account (name, balance) values ('ann', 1000), ('raj', 500);
src/main/java/.../tx/Accounts.java
//Accounts.java
package com.javabrahman.aop.proxies.tx;

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

@Repository
public class Accounts {
  private final JdbcClient jdbc;

  public Accounts(JdbcClient jdbc) {
    this.jdbc = jdbc;
  }

  public void debit(String name, int amount) {
    jdbc.sql("update account set balance = balance - ? where name = ?").params(amount, name).update();
  }

  public void credit(String name, int amount) {
    int rows = jdbc.sql("update account set balance = balance + ? where name = ?").params(amount, name).update();
    if (rows == 0) {
      throw new IllegalStateException("no account named " + name);
    }
  }

  public String balances() {
    return jdbc.sql("select name || '=' || balance from account order by name").query(String.class).list().toString();
  }
}
A transfer takes money from one account and gives it to another, in one transaction: if the credit fails, the debit must be rolled back. payEach() pays several people, by calling transfer() for each -
src/main/java/.../tx/TransferService.java
//TransferService.java
package com.javabrahman.aop.proxies.tx;

import java.util.List;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
public class TransferService {
  private final Accounts accounts;
  private final ObjectProvider<TransferService> self;   // used by fix 2

  public TransferService(Accounts accounts, ObjectProvider<TransferService> self) {
    this.accounts = accounts;
    this.self = self;
  }

  @Transactional
  public void transfer(String from, String to, int amount) {
    System.out.println("   transfer " + amount + " " + from + " -> " + to + ", in a transaction: "
        + TransactionSynchronizationManager.isActualTransactionActive());
    accounts.debit(from, amount);
    accounts.credit(to, amount);   // fails for an unknown account
  }

  // THE TRAP: this.transfer() does not go through the proxy
  public void payEach(String from, List<String> recipients, int amount) {
    for (String to : recipients) {
      transfer(from, to, amount);
    }
  }

  // FIX 2: call the method through the proxy of this bean, looked up when needed
  public void payEachThroughSelf(String from, List<String> recipients, int amount) {
    for (String to : recipients) {
      self.getObject().transfer(from, to, amount);
    }
  }

  // FIX 3: ask Spring for the proxy that is handling the current call (needs exposeProxy = true)
  public void payEachThroughAopContext(String from, List<String> recipients, int amount) {
    for (String to : recipients) {
      ((TransferService) AopContext.currentProxy()).transfer(from, to, amount);
    }
  }
}
src/main/java/.../tx/PaymentBatch.java - fix 1: the loop in another bean
//PaymentBatch.java
package com.javabrahman.aop.proxies.tx;

import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class PaymentBatch {
  private final TransferService transfers;   // the proxy

  public PaymentBatch(TransferService transfers) {
    this.transfers = transfers;
  }

  public void payEach(String from, List<String> recipients, int amount) {
    for (String to : recipients) {
      transfers.transfer(from, to, amount);
    }
  }
}
Each call below tries to pay 100 from Ann to "zed", an account that does not exist, so every credit fails -
src/main/java/.../tx/SelfInvocationApp.java
//SelfInvocationApp.java
package com.javabrahman.aop.proxies.tx;

import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)   // only needed by fix 3
public class SelfInvocationApp {

  public static void main(String[] args) {
    try (ConfigurableApplicationContext context = SpringApplication.run(SelfInvocationApp.class, args)) {
      TransferService transfers = context.getBean(TransferService.class);
      PaymentBatch batch = context.getBean(PaymentBatch.class);
      Accounts accounts = context.getBean(Accounts.class);
      System.out.println("start: " + accounts.balances());

      attempt("1. transfer() called by the application, through the proxy",
          () -> transfers.transfer("ann", "zed", 100), accounts);
      attempt("2. payEach() calls this.transfer() - self-invocation",
          () -> transfers.payEach("ann", List.of("zed"), 100), accounts);
      attempt("3. fix 1: the loop moved to another bean",
          () -> batch.payEach("ann", List.of("zed"), 100), accounts);
      attempt("4. fix 2: the call goes through the bean's own proxy",
          () -> transfers.payEachThroughSelf("ann", List.of("zed"), 100), accounts);
      attempt("5. fix 3: AopContext.currentProxy()",
          () -> transfers.payEachThroughAopContext("ann", List.of("zed"), 100), accounts);
    }
  }

  static void attempt(String title, Runnable call, Accounts accounts) {
    System.out.println(title);
    try {
      call.run();
    } catch (IllegalStateException e) {
      System.out.println("   failed: " + e.getMessage());
    }
    System.out.println("   balances now: " + accounts.balances());
  }
}
 OUTPUT of the above code
start: [ann=1000, raj=500]
1. transfer() called by the application, through the proxy
   transfer 100 ann -> zed, in a transaction: true
   failed: no account named zed
   balances now: [ann=1000, raj=500]
2. payEach() calls this.transfer() - self-invocation
   transfer 100 ann -> zed, in a transaction: false
   failed: no account named zed
   balances now: [ann=900, raj=500]
3. fix 1: the loop moved to another bean
   transfer 100 ann -> zed, in a transaction: true
   failed: no account named zed
   balances now: [ann=900, raj=500]
4. fix 2: the call goes through the bean's own proxy
   transfer 100 ann -> zed, in a transaction: true
   failed: no account named zed
   balances now: [ann=900, raj=500]
5. fix 3: AopContext.currentProxy()
   transfer 100 ann -> zed, in a transaction: true
   failed: no account named zed
   balances now: [ann=900, raj=500]
  • Call 1 went through the proxy: the transfer ran in a transaction, the failed credit rolled the debit back, and Ann still has 1000.
  • Call 2 ran the very same transfer() method, with its @Transactional, but reached it through this. No transaction started, each statement committed on its own, and when the credit failed the debit had already been committed: 100 vanished. Nothing in the log warns about it.
  • Calls 3 to 5 all reached transfer() through the proxy again, so the transaction started and Ann's balance stayed at 900.

The three fixes compared

  • Fix 1, move the caller to another bean: the cleanest. The class with the loop and the class with the transactional unit of work are often two different responsibilities anyway. This is the fix to reach for first.
  • Fix 2, call through the bean's own proxy: injecting an ObjectProvider of the class itself (or a field with @Lazy) gives access to the proxy without a circular-dependency error at startup. It works, but the code now depends on knowing that it is proxied.
  • Fix 3, AopContext.currentProxy(): needs exposeProxy = true, which makes Spring keep the current proxy in a thread-local during every advised call, and ties the code to Spring's AOP API. A last resort.
A fourth option avoids the problem altogether: when a method needs several transactions of its own, TransactionTemplate starts them in code, with no proxy involved. And the trap is not specific to transactions: a @Cacheable, @Async or @Retryable method called through this is not cached, not asynchronous, not retried - later tutorials of this module show it again.

The final method trap

A CGLIB proxy is a subclass, and a subclass cannot override a final method. So a call to a final method on the proxy does not go to the interceptors and the target: it runs the inherited code on the proxy object itself. Worse, Spring creates the proxy instance without running a constructor, so the fields of the proxy object are all null. The method then sees null where the target has its dependencies -
src/main/java/.../finals/Stamper.java
//Stamper.java
package com.javabrahman.aop.proxies.finals;

import org.springframework.stereotype.Component;

@Component
public class Stamper {

  public String stamp(String text) {
    return "[APPROVED] " + text;
  }
}
src/main/java/.../finals/InvoiceService.java
//InvoiceService.java
package com.javabrahman.aop.proxies.finals;

import org.springframework.stereotype.Service;

@Service
public class InvoiceService {
  private final Stamper stamper;

  public InvoiceService(Stamper stamper) {
    this.stamper = stamper;
  }

  public String invoice(int amount) {
    return stamper.stamp("invoice for " + amount);
  }

  public final String finalInvoice(int amount) {   // final: the proxy cannot override it
    return stamper.stamp("final invoice for " + amount);
  }
}
src/main/java/.../finals/InvoiceAspect.java
//InvoiceAspect.java
package com.javabrahman.aop.proxies.finals;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class InvoiceAspect {

  @Before("execution(* com.javabrahman.aop.proxies.finals.InvoiceService.*(..))")
  public void log(JoinPoint joinPoint) {
    System.out.println("   [aspect] " + joinPoint.getSignature().getName());
  }
}
src/main/java/.../finals/FinalMethodApp.java
//FinalMethodApp.java
package com.javabrahman.aop.proxies.finals;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

@SpringBootApplication
public class FinalMethodApp {

  public static void main(String[] args) {
    try (ConfigurableApplicationContext context = SpringApplication.run(FinalMethodApp.class, args)) {
      InvoiceService invoices = context.getBean(InvoiceService.class);
      System.out.println("invoice(500):");
      System.out.println("   " + invoices.invoice(500));
      System.out.println("finalInvoice(500):");
      try {
        System.out.println("   " + invoices.finalInvoice(500));
      } catch (NullPointerException e) {
        System.out.println("   NullPointerException: " + e.getMessage());
      }
    }
  }
}
The run switches on the INFO messages of Spring's CGLIB proxy class, which notices the final method at startup -  OUTPUT of the above code
Public final method [public final java.lang.String com.javabrahman.aop.proxies.finals.InvoiceService.finalInvoice(int)] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies.
invoice(500):
   [aspect] invoice
   [APPROVED] invoice for 500
finalInvoice(500):
   NullPointerException: Cannot invoke "com.javabrahman.aop.proxies.finals.Stamper.stamp(String)" because "this.stamper" is null
invoice() went through the aspect and the target. finalInvoice() ran no advice, and ran on the proxy, whose stamper field is null. The same applies to private and static methods, which a subclass cannot override either: they are never advised. Kotlin classes, final by default, need the kotlin-spring compiler plugin that opens Spring-annotated classes for this reason.

Rules of thumb

  • Advice, transactions, caching, async and retries apply only to calls from another bean, through the proxy.
  • Put annotations such as @Transactional on public, non-final methods of non-final classes.
  • Keep Spring Boot's CGLIB default, and still prefer to inject interfaces where a class has one.
  • When a method's annotation seems to be ignored, check two things first: is the call coming through this, and is the method final, private or static?
  • Do not compare beans with getClass() == CardPayments.class: an advised bean's class is the proxy's. Use AopUtils.getTargetClass(bean) or instanceof.

Summary

In this tutorial we saw that Spring applies aspects and its own annotations through proxies. A JDK dynamic proxy implements the bean's interfaces; a CGLIB proxy is a generated subclass of its class, and Spring Boot uses CGLIB by default, so that advised beans can still be injected by class - with JDK proxies, the lookup by class failed. Calls that a bean makes to itself bypass the proxy: a @Transactional method called through this ran without a transaction and lost 100 from an account, and moving the loop to another bean, calling through the bean's own proxy, or AopContext.currentProxy() fixed it. Final methods are not advised either, and run on the proxy object, whose fields are null. The next tutorial applies one of Spring's built-in aspects: caching with @CacheableClick to read tutorial: Caching in Spring Boot with @Cacheable and Caffeine.