Command Palette

Search for a command to run...

[Advanced Spring Boot] Spring AOP: JDK and CGLIB Proxies, Aspects and Self-Invocation

The previous article ended with a BeanPostProcessor that swapped a bean for a JDK dynamic proxy and noted that Spring's own AnnotationAwareAspectJAutoProxyCreator does exactly the same thing on a much larger scale. This article is that machinery in full: which proxy Spring builds, what each kind cannot do, how an aspect selects the methods it wraps, and why the annotation on a method you call from inside the same class does nothing at all.

The examples use Spring Boot 4.1.1 and Java 21. This subject has more pre-Boot-3 folklore attached to it than any other corner of Spring, so this article checks each piece of that folklore against what Boot 4 actually does.

A caller crossing the proxy boundary and being advised, against an internal this.method() call that stays inside the target

The first section is a Boot 4 packaging change you will hit before you write a line of aspect code. Everything after it is the proxy itself.

The AOP starter that no longer exists

Every AOP tutorial written before 2026 begins the same way: add spring-boot-starter-aop. On Boot 4.1.1 that artifact is not in the bill of materials, and Spring Initializr does not know the word:

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&dependencies=aop"
Text
{"timestamp":"2026-09-18T03:01:58.679Z","status":400,"error":"Bad Request","message":"Unknown dependency 'aop' check project metadata","path":"/starter.zip"}

That is not a typo in the request. The Initializr metadata for Boot 4.1.1 lists 204 dependencies in 23 groups, and searching every field of every one of them for aop or aspect returns nothing. The BOM agrees — spring-boot-starter-aop does not appear anywhere in spring-boot-dependencies-4.1.1.pom, and Maven Central's metadata for the artifact stops at 4.0.0-M2. Requesting the 4.1.1 version returns 404.

The replacement is in the BOM under a different name, and you add it by hand:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-aspectj'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	runtimeOnly 'com.h2database:h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-aspectj-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}

No version: the Boot plugin's BOM resolves it. What it brings, from ./gradlew dependencies --configuration compileClasspath on a project whose only other dependency is spring-boot-starter-webmvc:

Text
+--- org.springframework.boot:spring-boot-starter-aspectj -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1
|    +--- org.springframework:spring-aop:7.0.9 (*)
|    \--- org.aspectj:aspectjweaver:1.9.25.1

Three things, and only one of them is new to a typical application. spring-aop is already on the classpath of every Spring Boot project, because spring-context depends on it. spring-boot-starter is already there. The artifact the starter actually adds is org.aspectj:aspectjweaver, pinned by the BOM property aspectj.version = 1.9.25.1.

What the starter actually switches on

aspectjweaver is not optional decoration. AopAutoConfiguration in 4.1.1 branches on one class from it, which javap -v reads straight out of the jar:

Text
AopAutoConfiguration
  @ConditionalOnBooleanProperty(name = ["spring.aop.auto"], matchIfMissing = true)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration
  @ConditionalOnClass(org.aspectj.weaver.Advice.class)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration$CglibAutoProxyConfiguration
  @EnableAspectJAutoProxy(proxyTargetClass = true)
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], matchIfMissing = true)
 
AopAutoConfiguration$AspectJAutoProxyingConfiguration$JdkDynamicAutoProxyConfiguration
  @EnableAspectJAutoProxy(proxyTargetClass = false)
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], havingValue = false)
 
AopAutoConfiguration$ClassProxyingConfiguration
  @ConditionalOnMissingClass("org.aspectj.weaver.Advice")
  @ConditionalOnBooleanProperty(name = ["spring.aop.proxy-target-class"], matchIfMissing = true)

Without org.aspectj.weaver.Advice on the classpath, Boot takes the ClassProxyingConfiguration branch: proxies are still created — that is how @Transactional keeps working — but @EnableAspectJAutoProxy is never applied, so no @Aspect class is ever read. With the class present, @Aspect processing is switched on and spring.aop.proxy-target-class picks the proxy kind.

Two properties, and those are all of them. From spring-configuration-metadata.json inside spring-boot-autoconfigure-4.1.1.jar:

PropertyTypeDefaultWhat it does
spring.aop.autoBooleantrueadds @EnableAspectJAutoProxy
spring.aop.proxy-target-classBooleantrueCGLIB subclass proxies (true) instead of interface-based JDK proxies (false)

There is no spring.aop.expose-proxy. That one is only reachable through the annotation, which matters in the self-invocation section below.

Do you need the starter at all?

Often not, and the honest answer is worth knowing before you add a dependency. spring-boot-starter-data-jpa pulls in spring-aspects, which pulls in aspectjweaver:

Text
|    \--- org.springframework:spring-aspects:7.0.9
|         \--- org.aspectj:aspectjweaver:1.9.25 -> 1.9.25.1

The lab application for this article was rebuilt with the AspectJ starter line deleted, and the condition report printed by --debug was unchanged:

Text
   AopAutoConfiguration matched:
      - @ConditionalOnBooleanProperty (spring.aop.auto=true) matched (OnPropertyCondition)
 
   AopAutoConfiguration.AspectJAutoProxyingConfiguration matched:
      - @ConditionalOnClass found required class 'org.aspectj.weaver.Advice' (OnClassCondition)
 
   AopAutoConfiguration.AspectJAutoProxyingConfiguration.CglibAutoProxyConfiguration matched:
      - @ConditionalOnBooleanProperty (spring.aop.proxy-target-class=true) matched (OnPropertyCondition)

Every aspect in the application still ran. On a project that has no JPA — spring-boot-starter-webmvc alone — the same aspect does not even compile:

Text
error: package org.aspectj.lang does not exist
import org.aspectj.lang.ProceedingJoinPoint;
                       ^
error: cannot find symbol
@Aspect
 ^
  symbol: class Aspect

So: add spring-boot-starter-aspectj when you write aspects, because it declares the dependency your source files actually have. Do not assume it is what made AOP work — on a JPA application, AspectJ arrived long before you asked for it.

Which proxy Spring builds, and from where

The object that builds every proxy is a BeanPostProcessor, the one the previous article measured at entry 9 of the chain. It is registered under a fixed bean name, so you can ask the context for it directly:

src/main/java/com/example/demo/lab/AopLab.java
Trace.note("auto-proxy creator: "
        + context.getBean("org.springframework.aop.config.internalAutoProxyCreator").getClass().getName());
Text
    auto-proxy creator: org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator

In postProcessAfterInitialization it asks every advisor in the context whether it applies to the bean, and if any does, it returns a proxy in the bean's place. Everything in this article is a consequence of that one substitution.

The service it will proxy is deliberately ordinary — an interface, an implementation, and one aspect that prints when it fires:

src/main/java/com/example/demo/pricing/PriceService.java
package com.example.demo.pricing;
 
import java.math.BigDecimal;
 
public interface PriceService {
 
    BigDecimal quote(String sku, int quantity);
 
    String name();
}
src/main/java/com/example/demo/pricing/ListPriceService.java
package com.example.demo.pricing;
 
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
 
@Service
public class ListPriceService implements PriceService {
 
    private final Map<String, BigDecimal> catalog = new HashMap<>();
 
    public ListPriceService() {
        catalog.put("SKU-1", new BigDecimal("19.90"));
        catalog.put("SKU-2", new BigDecimal("4.50"));
        System.out.println("    ListPriceService constructor ran on instance @"
                + Integer.toHexString(System.identityHashCode(this)));
    }
 
    @Override
    public BigDecimal quote(String sku, int quantity) {
        return catalog.getOrDefault(sku, BigDecimal.ZERO).multiply(BigDecimal.valueOf(quantity));
    }
 
    @Override
    public String name() {
        return "list";
    }
 
    /** final on purpose: CGLIB cannot override it. */
    public final String describe() {
        return "catalog holds " + catalog.size() + " prices";
    }
}

JDK dynamic proxy or CGLIB subclass

The folklore says Spring uses a JDK dynamic proxy whenever the target implements an interface. On Boot that has been false since 2.0, and the trace says so. ListPriceService implements PriceService, and with default settings the bean is a CGLIB subclass:

Text
    bean class              com.example.demo.pricing.ListPriceService$$SpringCGLIB$$0
    AopUtils.isAopProxy     true
    isJdkDynamicProxy       false
    isCglibProxy            true
    ultimateTargetClass     com.example.demo.pricing.ListPriceService

The reason is the auto-configuration above: Boot applies @EnableAspectJAutoProxy(proxyTargetClass = true) because spring.aop.proxy-target-class defaults to true. Plain Spring Framework, without Boot, defaults the other way. Flip the property and the same bean comes back as a JDK proxy:

src/main/resources/application.properties
server.port=8203
spring.aop.proxy-target-class=false 
Text
    bean class              jdk.proxy2.$Proxy103
    AopUtils.isAopProxy     true
    isJdkDynamicProxy       true
    isCglibProxy            false
    ultimateTargetClass     com.example.demo.pricing.ListPriceService
    proxiedInterfaces       [PriceService]

jdk.proxy2 is the dynamic module the JDK puts generated proxy classes in; the number after $Proxy is a counter, so it changes as beans are added. Four helpers answer the "what am I holding" question and are worth remembering, because getClass().getName() on a proxy tells you nothing useful:

CallAnswers
AopUtils.isAopProxy(bean)is this object a Spring AOP proxy at all
AopUtils.isJdkDynamicProxy(bean)is it a java.lang.reflect.Proxy
AopUtils.isCglibProxy(bean)is it a generated subclass
AopProxyUtils.ultimateTargetClass(bean)the class of the object underneath, unwrapping nested proxies

The same @Service proxied both ways: the class names, what each proxy advises, what it silently misses and what it fails on

What a JDK proxy breaks

A JDK dynamic proxy implements the target's interfaces and nothing else. It is not a ListPriceService, so casting to the implementation class throws:

Text
    cast to ListPriceService threw
      java.lang.ClassCastException: class jdk.proxy2.$Proxy103 cannot be cast to class com.example.demo.pricing.ListPriceService (jdk.proxy2.$Proxy103 is in module jdk.proxy2 of loader org.springframework.boot.loader.launch.LaunchedClassLoader @378bf509; com.example.demo.pricing.ListPriceService is in unnamed module of loader org.springframework.boot.loader.launch.LaunchedClassLoader @378bf509)

In practice you rarely write that cast; the container writes it for you the moment a bean asks for the implementation type instead of the interface:

src/main/java/com/example/demo/config/ImplInjectionBean.java
@Component
public class ImplInjectionBean {
 
    private final ListPriceService prices;
 
    ImplInjectionBean(ListPriceService prices) {
        this.prices = prices;
    }
}

With spring.aop.proxy-target-class=false that application does not start, and Boot's failure analyzer explains it properly:

Text
APPLICATION FAILED TO START
***************************
 
Description:
 
The bean 'listPriceService' could not be injected because it is a JDK dynamic proxy
 
The bean is of type 'jdk.proxy2.$Proxy103' and implements:
	com.example.demo.pricing.PriceService
	org.springframework.aop.SpringProxy
	org.springframework.aop.framework.Advised
	org.springframework.core.DecoratingProxy
 
Expected a bean of type 'com.example.demo.pricing.ListPriceService' which implements:
	com.example.demo.pricing.PriceService
 
 
Action:
 
Consider injecting the bean as one of its interfaces or forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.

This is why Boot changed the default. A CGLIB subclass is the target type, so injecting by implementation class keeps working and nobody has to care which proxy was built. Leave spring.aop.proxy-target-class alone unless you have a specific reason — the usual one being a target class you cannot subclass.

What a CGLIB proxy cannot do

A CGLIB proxy is a generated subclass that overrides the target's methods. Everything it cannot override is a hole in the advice. Five method kinds, in one class, each called through the bean with an aspect matching execution(* com.example.demo.limits.LimitsService.*(..)):

src/main/java/com/example/demo/limits/LimitsService.java
package com.example.demo.limits;
 
import com.example.demo.lab.Trace;
import org.springframework.stereotype.Service;
 
@Service
public class LimitsService {
 
    public String publicMethod() {
        return "public";
    }
 
    public final String finalMethod() {
        return "final";
    }
 
    protected String protectedMethod() {
        return "protected";
    }
 
    String packagePrivateMethod() {
        return "package-private";
    }
 
    private String privateMethod() {
        return "private";
    }
 
    /** The only way to reach the private method: an advised public method calls it. */
    public String callsPrivateMethod() {
        Trace.note("callsPrivateMethod(): this = " + this.getClass().getSimpleName());
        return privateMethod();
    }
}

The run lists which methods the generated subclass declares, then calls each one:

Text
    bean class: com.example.demo.limits.LimitsService$$SpringCGLIB$$0
    protectedMethod        [protected]        overridden by the proxy class = true
    packagePrivateMethod   []                 overridden by the proxy class = true
    callsPrivateMethod     [public]           overridden by the proxy class = true
    publicMethod           [public]           overridden by the proxy class = true
    finalMethod            [public final]     overridden by the proxy class = false
    privateMethod          [private]          overridden by the proxy class = false
    --- calling each one through the bean ---
    [LimitsAspect] ADVISED publicMethod
    publicMethod()          -> public
    finalMethod()           -> final
    [LimitsAspect] ADVISED protectedMethod
    protectedMethod()       -> protected
    [LimitsAspect] ADVISED packagePrivateMethod
    packagePrivateMethod()  -> package-private
    [LimitsAspect] ADVISED callsPrivateMethod
    callsPrivateMethod(): this = LimitsService
    callsPrivateMethod()    -> private

That output is the table this section exists for:

TargetOverridden by the proxyAdvice runsHow it fails
public methodyesyes
protected methodyesyes
package-private methodyesyes— (the subclass is generated in the same package and class loader)
final methodnonosilently, and worse than silently — see below
private methodnonosilently; it is self-invocation by definition
final classloudly: the context fails to start

Two of those rows contradict what most articles say. "Only public methods can be advised" is a rule for @Transactional with allowPublicMethodsOnly, not for Spring AOP: protected and package-private methods were advised here. And a final method does not throw — it is quietly skipped, which is the dangerous half.

A final class fails at startup

Put final on a @Service class with a matching pointcut and there is nothing CGLIB can generate:

Text
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'finalPriceService' defined in URL […/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/limits/FinalPriceService.class]: Could not generate CGLIB subclass of class com.example.demo.limits.FinalPriceService: Common causes of this problem include using a final class or a non-visible class
Caused by: org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class com.example.demo.limits.FinalPriceService: Common causes of this problem include using a final class or a non-visible class
	at org.springframework.aop.framework.CglibAopProxy.buildProxy(CglibAopProxy.java:236)
	at org.springframework.aop.framework.ProxyFactory.getProxy(ProxyFactory.java:110)
	at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.createProxy(AbstractAutoProxyCreator.java:431)
	at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.postProcessAfterInitialization(AbstractAutoProxyCreator.java:289)
Caused by: java.lang.IllegalArgumentException: Cannot subclass final class com.example.demo.limits.FinalPriceService
	at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:653)
	at org.springframework.aop.framework.ObjenesisCglibAopProxy.createProxyClassAndInstance(ObjenesisCglibAopProxy.java:62)

This one is easy to live with, because it happens on the first startup after you write it. If the class has an interface, spring.aop.proxy-target-class=false gets you a JDK proxy instead; otherwise remove the final.

The proxy never runs your constructor

Look at the last frame of that stack trace: ObjenesisCglibAopProxy. Spring instantiates the generated subclass through Objenesis, which allocates an object without calling any constructor. That is what lets a target with a constructor that takes arguments, opens a connection or reads configuration be proxied at all — but it means the proxy instance's own fields are never initialised.

Two instances, one initialised, and the field read out of each by reflection:

Text
== 2. does the proxy run the target's constructor
    proxy instance          @1e489957
    target instance         @63551c66
    target.catalog          {SKU-1=19.90, SKU-2=4.50}
    proxy.catalog           null

The constructor's println appeared exactly once in the whole startup — on the target. Normally none of this is visible, because every overridden method delegates to the target and never touches the proxy's own fields. A final method is the exception: it is not overridden, so calling it on the proxy runs the target's bytecode with this bound to the uninitialised proxy.

Text
    cast to ListPriceService worked, describe() next
    describe() threw java.lang.NullPointerException: Cannot invoke "java.util.Map.size()" because "this.catalog" is null

describe() is three lines of correct Java that throws NullPointerException whenever the bean is proxied. Spring warns about it, at WARN for a method that implements an interface and at DEBUG for the rest, and the DEBUG message names the exact failure:

Text
WARN  CglibAopProxy: Public final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB, consider removing the final marker or using interface-based JDK proxies.
DEBUG CglibAopProxy: Final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

⚠️ final on a method of a Spring bean is not a safety feature. It either loses the advice or, if the method reads a field, produces a NullPointerException in code that is obviously correct.

Writing an aspect

An aspect is a @Component carrying @Aspect. Both annotations are needed: @Component makes it a bean, @Aspect tells the auto-proxy creator to read advice out of it. The pointcut expression is AspectJ syntax, evaluated by Spring at proxy-creation time.

src/main/java/com/example/demo/ordering/AdviceKindAspect.java
package com.example.demo.ordering;
 
import com.example.demo.lab.Trace;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
 
/** All five advice kinds on one named pointcut. */
@Aspect
@Component
public class AdviceKindAspect {
 
    @Pointcut("execution(* com.example.demo.ordering.OrderLabService.handle(..))")
    void handleCall() {
    }
 
    @Around("handleCall()")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Trace.step("@Around        before proceed()");
        try {
            Object result = pjp.proceed();
            Trace.step("@Around        after proceed(), result = " + result);
            return result;
        } catch (RuntimeException ex) {
            Trace.step("@Around        proceed() threw " + ex.getClass().getSimpleName());
            throw ex;
        }
    }
 
    @Before("handleCall()")
    public void before(JoinPoint jp) {
        Trace.step("@Before        args = " + java.util.Arrays.toString(jp.getArgs()));
    }
 
    @AfterReturning(pointcut = "handleCall()", returning = "result")
    public void afterReturning(Object result) {
        Trace.step("@AfterReturning result = " + result);
    }
 
    @AfterThrowing(pointcut = "handleCall()", throwing = "ex")
    public void afterThrowing(Throwable ex) {
        Trace.step("@AfterThrowing " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
    }
 
    @After("handleCall()")
    public void after() {
        Trace.step("@After         runs either way");
    }
}

The empty void handleCall() method is a named pointcut: it has no body and is never called, it only carries the expression. Five advices reference it by name. When the expression changes you edit one string, not five, and a named pointcut can be referenced from another aspect as com.example.demo.ordering.AdviceKindAspect.handleCall().

The pointcut designators worth knowing

AspectJ has a dozen designators; Spring AOP supports a subset, and five of them cover nearly everything. One aspect declares one @Before per designator, and a run calls four methods on two beans so you can see exactly which one each designator selects:

src/main/java/com/example/demo/designator/DesignatorAspect.java
@Aspect
@Component
public class DesignatorAspect {
 
    @Before("execution(public String com.example.demo.designator.AlphaService.*(String))")
    public void byExecution(JoinPoint jp) {
        Trace.note("execution     -> " + signature(jp));
    }
 
    @Before("within(com.example.demo.designator..*)")
    public void byWithin(JoinPoint jp) {
        Trace.note("within        -> " + signature(jp));
    }
 
    @Before("@annotation(com.example.demo.designator.Audited)")
    public void byAnnotation(JoinPoint jp) {
        Trace.note("@annotation   -> " + signature(jp));
    }
 
    @Before("bean(betaService)")
    public void byBean(JoinPoint jp) {
        Trace.note("bean          -> " + signature(jp));
    }
 
    @Before("within(com.example.demo.designator..*) && args(sku, quantity)")
    public void byArgs(JoinPoint jp, String sku, int quantity) {
        Trace.note("args          -> " + signature(jp) + " sku=" + sku + " quantity=" + quantity);
    }
}
Text
== 4. pointcut designators
    --- alpha.plain("x") ---
    execution     -> AlphaService.plain
    within        -> AlphaService.plain
    --- alpha.audited("x") ---
    @annotation   -> AlphaService.audited
    execution     -> AlphaService.audited
    within        -> AlphaService.audited
    --- beta.plain("x") ---
    bean          -> BetaService.plain
    within        -> BetaService.plain
    --- beta.withTwoArgs("SKU-1", 3) ---
    args          -> BetaService.withTwoArgs sku=SKU-1 quantity=3
    bean          -> BetaService.withTwoArgs
    within        -> BetaService.withTwoArgs
DesignatorSelectsNotes
execution(…)method executions by signaturethe only one that can filter on modifiers, return type and parameter types; .. in a package means "and subpackages", (..) means "any parameters"
within(…)everything declared in a type or packagecheap and coarse; the usual choice for a whole layer
@annotation(…)methods carrying an annotationhow you build your own @Audited, @Timed, @RateLimited
bean(…)methods on beans whose name matchesSpring-only, not AspectJ; supports * so bean(*Repository) works
args(…)calls whose runtime arguments matchalso binds the arguments into the advice parameters, as sku and quantity above

args is the one that surprises people: it is evaluated per call, not per method, so it is the only designator in this list that costs something at runtime. The && within(…) in front of it is not decoration — without a static designator to narrow the candidates first, args is tested against every method of every bean in the context.

Combine designators with &&, || and !. @annotation(Audited) && within(com.example.demo.service..*) is a common shape: your annotation, but only where you meant it.

The five advice kinds and the order they run in

The measured order for a call that returns normally, from the aspect above, each line printed by the advice itself:

Text
== 5. advice order, normal return
 1  @Around        before proceed()
 2  @Before        args = [ok]
 3  target method  handle("ok")
 4  @AfterReturning result = OK
 5  @After         runs either way
 6  @Around        after proceed(), result = OK
    result = OK

and for the same call when the target throws:

Text
== 5b. advice order, the target throws
 1  @Around        before proceed()
 2  @Before        args = [boom]
 3  target method  handle("boom")
 4  @AfterThrowing IllegalStateException: handle failed on purpose
 5  @After         runs either way
 6  @Around        proceed() threw IllegalStateException
 7  caller caught  IllegalStateException: handle failed on purpose

The measured order of the five advice kinds around one call, for a normal return and for a thrown exception

Four facts fall out of those two traces.

@Around is outermost on both sides. It is the only advice that owns the call: everything else happens between its proceed() and the value coming back.

@After runs before @Around resumes. Step 5 precedes step 6. The Spring Framework reference gives @Around, @Before, @After, @AfterReturning, @AfterThrowing as the precedence order within one aspect, with @After invoked after the returning and throwing advice, following AspectJ's "after finally advice" semantics. A great deal of older material still draws @After outside @Around. It is finally for the target method, not for the whole advice chain.

@AfterReturning and @AfterThrowing are mutually exclusive, and neither can change the outcome. @AfterReturning sees the value but cannot replace it; @AfterThrowing sees the exception but cannot swallow it — step 7 shows the caller receiving the same IllegalStateException.

@Before cannot stop the call except by throwing. If it throws, the target never runs and the exception goes to the caller.

AdviceSignature it usually takesCan it change the outcome
@AroundObject around(ProceedingJoinPoint pjp) throws Throwableyes — arguments, return value, exceptions, and whether the target runs at all
@Beforevoid before(JoinPoint jp)only by throwing
@AfterReturningvoid afterReturning(Object result)no
@AfterThrowingvoid afterThrowing(Throwable ex)no
@Aftervoid after()only by throwing

Reach for the weakest advice that does the job. @Around is the only one that can forget to call proceed(), or call it twice, or swallow an exception the caller needed.

ProceedingJoinPoint: arguments, return value and exceptions

@Around receives a ProceedingJoinPoint, and proceed() has an overload that takes a replacement argument array. Everything an around advice can do is in these two methods:

src/main/java/com/example/demo/rewrite/RewriteAspect.java
package com.example.demo.rewrite;
 
import com.example.demo.lab.Trace;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
 
@Aspect
@Component
public class RewriteAspect {
 
    @Around("execution(* com.example.demo.rewrite.RewriteService.greet(..))")
    public Object rewrite(ProceedingJoinPoint pjp) throws Throwable {
        Object[] args = pjp.getArgs();
        Trace.note("caller passed  " + java.util.Arrays.toString(args));
        args[0] = ((String) args[0]).toUpperCase(java.util.Locale.ROOT);
        Object result = pjp.proceed(args);
        Trace.note("target returned \"" + result + "\"");
        return result + " [rewritten]";
    }
 
    @Around("execution(* com.example.demo.rewrite.RewriteService.fail(..))")
    public Object swallow(ProceedingJoinPoint pjp) {
        try {
            return pjp.proceed();
        } catch (Throwable ex) {
            Trace.note("swallowed      " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
            return "fallback";
        }
    }
}
Text
== 6. ProceedingJoinPoint
    caller passed  [ada, 2]
    target sees name="ADA" times=2
    target returned "ADA ADA"
    caller got     "ADA ADA [rewritten]"
    swallowed      IllegalArgumentException: the target threw
    fail() got     "fallback"

The caller passed "ada", the target received "ADA", and the caller got back a string the target never produced. The second advice caught an IllegalArgumentException the target threw and returned a value instead, so the caller cannot tell anything went wrong.

Both are legitimate — retries, circuit breakers and caches are built on exactly this — and both are why an unexplained value in a debugger is often an aspect. Three rules keep it survivable:

  • Mutating pjp.getArgs() alone does nothing. The array is a copy. proceed(args) is what makes the target see it.
  • Rethrow unless swallowing is the feature. catch (Throwable ex) { return null; } inside an around advice hides production failures for years.
  • Do not change the return type. proceed() is typed Object; returning something the caller cannot cast produces a ClassCastException at a call site that looks innocent.

Note that JoinPoint also carries the metadata: getSignature(), getTarget() (the real object), getThis() (the proxy) and getArgs(). getSignature().getDeclaringType() is how a logging aspect knows which class it is decorating.

Several aspects on one method

Three aspects with the same pointcut, two of them ordered, one not:

src/main/java/com/example/demo/layered/FirstAspect.java
@Aspect
@Component
@Order(10)
public class FirstAspect {
 
    @Around("execution(* com.example.demo.layered.LayeredService.run(..))")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Trace.step("FirstAspect    @Order(10)  enter");
        try {
            return pjp.proceed();
        } finally {
            Trace.step("FirstAspect    @Order(10)  exit");
        }
    }
}

SecondAspect is the same with @Order(20), and UnorderedAspect is the same with no @Order at all. The measured nesting:

Text
== 7. three aspects on one method
 1  FirstAspect    @Order(10)  enter
 2  SecondAspect   @Order(20)  enter
 3  UnorderedAspect (no @Order)  enter
 4  target method  run()
 5  UnorderedAspect (no @Order)  exit
 6  SecondAspect   @Order(20)  exit
 7  FirstAspect    @Order(10)  exit

The lowest @Order value is outermost. Advisors are sorted ascending and the first one in the sorted chain wraps the rest, so @Order(10) sees the call first and returns last. Unlike on a BeanPostProcessor — where the previous article measured @Order being ignored entirely — @Order works here, because AOP advisors are sorted with AnnotationAwareOrderComparator, which reads the annotation. Implementing Ordered works identically.

An aspect with no order is innermost. Spring treats a missing order as Ordered.LOWEST_PRECEDENCE, which sorts last, which is closest to the target. That is a defensible default for a logging aspect and a bad one for a security aspect. Two unordered aspects have no defined order between them at all — do not leave a pair of aspects whose relative position matters without explicit @Order values.

The ordering that actually bites in production is between your aspect and Spring's own. @Transactional is applied by BeanFactoryTransactionAttributeSourceAdvisor, whose order is Ordered.LOWEST_PRECEDENCE by default, so an aspect with almost any explicit @Order runs outside the transaction — it sees the call before the transaction begins and after it has committed. If your aspect must run inside the transaction, give it an order higher than the transaction advisor's, or set @EnableTransactionManagement(order = …) and place yours above it.

Self-invocation, underneath @Transactional

Basics article 30 showed the symptom: a @Transactional method called from inside the same class runs with no transaction. This section is the reason, and it is one line of output.

src/main/java/com/example/demo/selfcall/ReportService.java
@Service
public class ReportService {
 
    private final ObjectProvider<ReportService> selfProvider;
    private final ReportService lazySelf;
 
    ReportService(ObjectProvider<ReportService> selfProvider, @Lazy ReportService lazySelf) {
        this.selfProvider = selfProvider;
        this.lazySelf = lazySelf;
    }
 
    /** The bug: an internal call runs on the target, so it never re-enters the proxy. */
    public void viaThis() {
        Trace.note("viaThis():            this = " + id(this));
        generate(1);
    }
 
    @Reported
    @Transactional
    public void generate(int n) {
        Trace.note("    generate(" + n + "): transaction active = "
                + TransactionSynchronizationManager.isActualTransactionActive());
    }
 
    public static String id(Object o) {
        return o.getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(o));
    }
}

@Reported is a custom annotation matched by a @Before advice that prints ADVICE RAN, so the run shows the AOP advice and the transaction failing together. The identities are the whole explanation:

Text
== 8. self-invocation
    the bean the caller holds: ReportService$$SpringCGLIB$$1@c247b02
    --- viaThis() ---
    viaThis():            this = ReportService@272c5abd
        generate(1): transaction active = false

The caller holds ReportService$$SpringCGLIB$$1@c247b02. Inside the method, this is ReportService@272c5abd — a different object, of a different class, at a different address. generate(1) compiles to this.generate(1), an ordinary virtual call on the target. No advice, no transaction, no warning, no log line. Nothing in the JVM is in a position to notice.

An external call crossing the proxy, an internal this-call bypassing it, and the fix that sends the internal call back through the proxy

The three fixes, measured

Self-injection. Ask the container for the bean again; what comes back is the proxy, because that is what is registered under the name.

src/main/java/com/example/demo/selfcall/ReportService.java
    /** Fix 1: ask the container for the bean again; what comes back is the proxy. */
    public void viaObjectProvider() {
        Trace.note("viaObjectProvider():  this = " + id(this) + ", self = " + id(selfProvider.getObject()));
        selfProvider.getObject().generate(2);
    }
 
    /** Fix 1b: the same idea with @Lazy on a constructor parameter. */
    public void viaLazySelf() {
        Trace.note("viaLazySelf():        this = " + id(this) + ", self = " + id(lazySelf));
        lazySelf.generate(3);
    }
Text
    --- viaObjectProvider() ---
    viaObjectProvider():  this = ReportService@272c5abd, self = ReportService$$SpringCGLIB$$1@c247b02
        [ReportAspect] ADVICE RAN for generate
        generate(2): transaction active = true
    --- viaLazySelf() ---
    viaLazySelf():        this = ReportService@272c5abd, self = ReportService$$SpringCGLIB$$0@7a85454b
        [ReportAspect] ADVICE RAN for generate
        generate(3): transaction active = true

Both work, and the difference between them is visible in the class names. ObjectProvider.getObject() returned the bean itself, $$SpringCGLIB$$1@c247b02 — the same object the caller is holding. @Lazy returned $$SpringCGLIB$$0@7a85454b, a second proxy created to defer the lookup, which then delegates to the first. It works, and it puts an extra object and an extra hop in a picture that is already hard enough to reason about.

AopContext.currentProxy(). The proxy can publish itself into a ThreadLocal for the duration of the call, but only if you ask for it:

src/main/java/com/example/demo/config/ExposeProxyConfig.java
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true) 
public class ExposeProxyConfig {
}

Without that annotation the call fails, loudly, which is at least honest:

Text
    --- viaAopContext() ---
    viaAopContext():      this = ReportService@272c5abd
      threw java.lang.IllegalStateException
      Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.

With it, the internal call is advised like any other:

Text
    --- viaAopContext() ---
    viaAopContext():      this = ReportService@7fcbc336
        [ReportAspect] ADVICE RAN for generate
        generate(4): transaction active = true

The cost is not in this snippet: exposeProxy = true is a context-wide switch that makes every proxied call in the application push and pop a ThreadLocal, and AopContext.currentProxy() only works on the thread the proxy was entered on — hand the call to an executor and it throws the same IllegalStateException. The cast back to ReportService also ties the code to the proxy kind: it works on a CGLIB proxy and throws ClassCastException on a JDK one.

Move the method to another bean. Every call then crosses the proxy because it is a call to a different object:

src/main/java/com/example/demo/selfcall/ReportBatch.java
package com.example.demo.selfcall;
 
import com.example.demo.lab.Trace;
import org.springframework.stereotype.Service;
 
/** Fix 3: the loop lives in another bean, so every call crosses the proxy. */
@Service
public class ReportBatch {
 
    private final ReportService reports;
 
    ReportBatch(ReportService reports) {
        this.reports = reports;
    }
 
    public void run() {
        Trace.note("ReportBatch.run():    injected ReportService = " + ReportService.id(reports));
        reports.generate(5);
    }
}
Text
    --- another bean calls it ---
    ReportBatch.run():    injected ReportService = ReportService$$SpringCGLIB$$1@c247b02
        [ReportAspect] ADVICE RAN for generate
        generate(5): transaction active = true
FixWhat it costsWhen it is right
another beanone classthe default — the split usually improves the design anyway
ObjectProvider<Self>one field, one getObject() calla genuine loop over an annotated method inside one cohesive service
@Lazy self-injectionone field, plus a second proxyno advantage over ObjectProvider; prefer the explicit one
AopContext.currentProxy()a context-wide ThreadLocal on every call, a cast, thread affinitylegacy code you cannot restructure

This series recommends the first one. A method that needs its own annotations applied is a method with its own responsibility, and moving it to a collaborator says so in the type system rather than in a comment. ObjectProvider is the acceptable second choice when the two methods genuinely belong together; exposeProxy is a last resort.

What it costs in practice: @Transactional and @Async

The two annotations that break this way are the two that matter most, and their failure modes are different.

@Transactional fails as transaction active = false above: no transaction is opened, so each repository call gets its own, dirty checking writes nothing, and a later failure rolls back nothing. Basics 30 measured the SQL that does and does not reach the database.

@Async fails by running on the caller's thread. That is a silent loss of the entire feature:

Text
    --- @Async through this ---
    asyncViaThis():       caller thread = main
        generateAsync(this): thread = main
    --- @Async through the proxy ---
        generateAsync(another bean): thread = task-1
        generateAsync(proxy): thread = task-2

Through the proxy the method ran on task-1 and task-2, the pooled executor threads. Through this it ran on main, synchronously, in order, blocking the caller for as long as it takes. An @Async method that was supposed to fire off three emails now adds three network round trips to the request that triggered it, and the only symptom is that the endpoint got slower.

What a proxy costs

Two methods on the same bean: add, matched by one no-op @Around, and subtract, matched by none. Both called 20 million times, three warm-up rounds discarded, best of seven measured rounds, against a direct call on the target object obtained from ((Advised) proxy).getTargetSource().getTarget(). The numbers are indicative.

With CGLIB proxies, Boot's default:

Text
    proxy is com.example.demo.bench.CalculatorService$$SpringCGLIB$$0
    advisors matching add(int,int)      [ExposeInvocationInterceptor, AspectJAroundAdvice]
    advisors matching subtract(int,int) [ExposeInvocationInterceptor]
    direct call on the target         0.24 ns/call
    CGLIB proxy, no aspect advice      44.97 ns/call
    CGLIB proxy, one @Around aspect    65.98 ns/call

and with spring.aop.proxy-target-class=false:

Text
    proxy is jdk.proxy2.$Proxy101
    advisors matching add(int,int)      [ExposeInvocationInterceptor, AspectJAroundAdvice]
    advisors matching subtract(int,int) [ExposeInvocationInterceptor]
    direct call on the target         0.24 ns/call
    JDK proxy, no aspect advice       40.33 ns/call
    JDK proxy, one @Around aspect     65.33 ns/call

Read these honestly. The direct call is a + b on a monomorphic call site, which the JIT inlines to nothing — 0.24 ns is not a method call, it is a loop increment. So the proxy numbers are effectively the whole cost, not an overhead on top of a comparable baseline.

JDK and CGLIB are the same speed. 40 ns against 45 ns for the dispatch, 65 ns against 66 ns with one aspect; an earlier pair of runs gave 41 and 44. The difference is inside the run-to-run noise. The choice between them is about what they can proxy, never about throughput.

Going through the proxy at all is most of the cost. Roughly 40 ns of the 65 is dispatch: boxing the arguments into an Object[], building a ReflectiveMethodInvocation, walking the interceptor chain and invoking the target reflectively. The @Around advice adds the rest — 21 ns on the CGLIB proxy, 25 ns on the JDK one — mostly the MethodInvocationProceedingJoinPoint it needs. Note that subtract is not un-proxied — ExposeInvocationInterceptor has a Pointcut.TRUE and is added to every bean that any AspectJ advisor touches.

For a service method it does not matter. 65 ns is 0.000065 ms. A thousand advised calls in one request cost 0.065 ms; a single SELECT over a network costs a hundred times that. The cases where it does matter are real but narrow: a proxied method called inside a tight loop, a @Cacheable lookup whose hit path is a HashMap read, an aspect on a repository method invoked once per row. Measure before you assume; do not de-proxy a service on principle.

Is this bean advised, and by what?

Every Spring AOP proxy implements org.springframework.aop.framework.Advised, so the proxy will tell you its own configuration. This is the debugging tool to reach for first:

src/main/java/com/example/demo/lab/AopLab.java
Object bean = context.getBean(name);
if (bean instanceof Advised advised) {
    Trace.note(name + " -> " + bean.getClass().getSimpleName()
            + ", " + advised.getAdvisors().length + " advisor(s), exposeProxy=" + advised.isExposeProxy());
    for (Advisor a : advised.getAdvisors()) {
        Trace.note("    " + a);
    }
}
Text
    reportService -> ReportService$$SpringCGLIB$$1, 4 advisor(s), exposeProxy=false
        org.springframework.scheduling.annotation.AsyncAnnotationAdvisor@382c90c2
        org.springframework.aop.interceptor.ExposeInvocationInterceptor.ADVISOR
        org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor: advice org.springframework.transaction.interceptor.TransactionInterceptor@63cd2cd2
        InstantiationModelAwarePointcutAdvisor: expression [@annotation(com.example.demo.selfcall.Reported)]; advice method [public void com.example.demo.selfcall.ReportAspect.before(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON
    betaService -> BetaService$$SpringCGLIB$$0, 4 advisor(s), exposeProxy=false
        org.springframework.aop.interceptor.ExposeInvocationInterceptor.ADVISOR
        InstantiationModelAwarePointcutAdvisor: expression [within(com.example.demo.designator..*) && args(sku, quantity)]; advice method [public void com.example.demo.designator.DesignatorAspect.byArgs(org.aspectj.lang.JoinPoint,java.lang.String,int)]; perClauseKind=SINGLETON
        InstantiationModelAwarePointcutAdvisor: expression [bean(betaService)]; advice method [public void com.example.demo.designator.DesignatorAspect.byBean(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON
        InstantiationModelAwarePointcutAdvisor: expression [within(com.example.demo.designator..*)]; advice method [public void com.example.demo.designator.DesignatorAspect.byWithin(org.aspectj.lang.JoinPoint)]; perClauseKind=SINGLETON

Each InstantiationModelAwarePointcutAdvisor prints its pointcut expression and the exact advice method, which turns "my aspect is not firing" into "my expression does not match" in one glance.

A bean with no advisors is simply not a proxy, and the check reports that too:

Text
    listPriceService: not a proxy (com.example.demo.pricing.ListPriceService)

Advisors are attached per bean; whether one applies to a particular method is a separate question, answered by the advisor's own pointcut:

src/main/java/com/example/demo/lab/Advisors.java
for (Advisor advisor : advised.getAdvisors()) {
    boolean applies = !(advisor instanceof PointcutAdvisor pointcutAdvisor)
            || (pointcutAdvisor.getPointcut().getClassFilter().matches(targetClass)
                    && pointcutAdvisor.getPointcut().getMethodMatcher().matches(method, targetClass));
    if (applies) {
        names.add(advisor.getAdvice().getClass().getSimpleName());
    }
}
Text
    --- which advisors apply to one method ---
    reportService.generate  [ExposeInvocationInterceptor, TransactionInterceptor, AspectJMethodBeforeAdvice]
    reportService.viaThis   [ExposeInvocationInterceptor]

When even that is not enough, logging.level.org.springframework.aop=TRACE prints the decisions as they are made at startup:

Text
TRACE AnnotationAwareAspectJAutoProxyCreator: Creating implicit proxy for bean 'listPriceService' with 0 common interceptors and 2 specific interceptors
TRACE CglibAopProxy: Creating CGLIB proxy: SingletonTargetSource for target object [com.example.demo.pricing.ListPriceService@6aa792]
DEBUG CglibAopProxy: Final method [public final java.lang.String com.example.demo.pricing.ListPriceService.describe()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.
TRACE CglibAopProxy: Unable to apply any optimizations to advised method: public java.math.BigDecimal com.example.demo.pricing.ListPriceService.quote(java.lang.String,int)
TRACE AnnotationAwareAspectJAutoProxyCreator: Did not attempt to auto-proxy infrastructure class [org.springframework.transaction.interceptor.TransactionInterceptor]

It is verbose — 320 lines from CglibAopProxy alone on this small application — so use it when the Advised check has not already answered the question.

Past the proxy: weaving, aspects on aspects, and Micrometer

Three things that bound this subject, each in a sentence.

AspectJ weaving is the way past every limit in this article. Compile-time or load-time weaving rewrites the bytecode of the class itself rather than wrapping it, so final methods, private methods, constructors, field access and self-invocation are all advisable — at the cost of a weaving step in your build or a -javaagent on your command line, which is why almost nobody reaches for it.

An aspect is not itself advised. AnnotationAwareAspectJAutoProxyCreator skips @Aspect beans, as well as the advisors and interceptors it classifies as infrastructure — the startup trace lists a Creating implicit proxy for bean line for all nine application services and not one for any aspect, so an aspect cannot accidentally advise itself into a loop.

Most of the aspects worth having are already written. io.micrometer.core.aop.TimedAspect in micrometer-core and io.micrometer.observation.aop.ObservedAspect in micrometer-observation are ordinary @Aspect classes; registering one as a bean gives you timers, traces and metrics on any method carrying @Timed or @Observed, better tested than the timing aspect you were about to write.

FAQ

Why is spring-boot-starter-aop not found on Spring Boot 4?

Because it no longer exists. It is absent from spring-boot-dependencies-4.1.1.pom, Maven Central's last published version of it is 4.0.0-M2, and Spring Initializr's metadata for 4.1.1 has no AOP or AspectJ entry at all — requesting dependencies=aop answers 400 Unknown dependency 'aop' check project metadata. Use org.springframework.boot:spring-boot-starter-aspectj (and spring-boot-starter-aspectj-test for tests), which resolves to 4.1.1 and brings spring-aop 7.0.9 and org.aspectj:aspectjweaver 1.9.25.1.

Does Spring use a JDK proxy or a CGLIB proxy?

On Spring Boot, CGLIB, even when the target implements an interface: spring.aop.proxy-target-class defaults to true, and the measured bean class was ListPriceService$$SpringCGLIB$$0. Setting the property to false produced jdk.proxy2.$Proxy103, which implements only PriceService and therefore cannot be injected into, or cast to, the implementation class. Plain Spring Framework without Boot defaults to JDK proxies where an interface exists, which is where the folklore comes from.

Why does @Transactional not work when I call the method from the same class?

Because the annotation is implemented by the proxy, and an internal call never reaches it. Measured here: the caller holds ReportService$$SpringCGLIB$$1@c247b02 while this inside the method is ReportService@272c5abd, so this.generate(1) is an ordinary virtual call on the target — transaction active = false, and the AOP advice did not run either. The same applies to @Async, @Cacheable, @Retryable and every other proxy-based annotation.

Can Spring AOP advise final, private or package-private methods?

Package-private and protected methods, yes — both were advised in this run, because the CGLIB subclass is generated in the same package and class loader and overrides them. final and private methods, no: the subclass cannot override either, so the advice is silently skipped. A final method is the worse of the two, because it then executes against the proxy instance, whose fields Objenesis never initialised — the measured result was NullPointerException: Cannot invoke "java.util.Map.size()" because "this.catalog" is null.

In what order do @Before, @Around, @After and @AfterReturning run?

Measured on Spring Framework 7.0.9: @Around up to proceed(), then @Before, then the target method, then @AfterReturning (or @AfterThrowing), then @After, and only then the rest of @Around. The part people get wrong is the last two: @After runs before @Around resumes, which is AspectJ's "after finally advice" semantics and which older diagrams routinely draw the other way round.

How do I control the order of several aspects?

Put @Order on the aspect class, or implement Ordered. The lowest value is outermost — measured as @Order(10), then @Order(20), then the target, unwinding in reverse. An aspect with no order is treated as LOWEST_PRECEDENCE and ends up innermost, next to the target, and two unordered aspects have no defined order between them. Note that the transaction advisor also sits at LOWEST_PRECEDENCE, so any aspect you order explicitly will run outside the transaction.

Is Spring AOP slow enough to worry about?

No, for anything that does I/O. Measured over 20 million calls, best of seven warmed rounds: 0.24 ns for a direct call the JIT inlined away, 44.97 ns through a CGLIB proxy with no aspect advice, 65.98 ns with one @Around; the JDK proxy numbers were 40.33 and 65.33, the same within noise. That is 0.065 ms per thousand advised calls, against hundreds of microseconds for one database round trip. Worry only where a proxied method is called in a tight loop.

What does spring.aop.auto=false actually turn off?

Aspects, and only aspects. With the property set, AopAutoConfiguration is skipped, so @EnableAspectJAutoProxy is never applied and the auto-proxy creator falls back to InfrastructureAdvisorAutoProxyCreator — measured, listPriceService came back as the plain class with no advisors, while reportService was still a CGLIB proxy carrying AsyncAnnotationAdvisor and TransactionInterceptor. So @Transactional and @Async keep working and every @Aspect in the application stops firing, without a single warning.

Conclusion

Everything in Spring AOP follows from one substitution: AnnotationAwareAspectJAutoProxyCreator returns a proxy where your bean used to be, and the advice lives on the proxy. Boot builds that proxy as a CGLIB subclass by default — ListPriceService$$SpringCGLIB$$0, not jdk.proxy2.$Proxy103 — which is why injecting by implementation class works, and why a final class stops the application while a final method quietly loses its advice and then throws NullPointerException against fields Objenesis never initialised. On Boot 4.1.1 the packaging changed too: spring-boot-starter-aop is gone, spring-boot-starter-aspectj replaces it, and on a JPA application AspectJ was already on the classpath before you asked.

The aspect itself is the easy part — @Aspect and @Component, a named @Pointcut, and five advice kinds whose measured order puts @Around outside everything and @After before @Around resumes. The hard part is the boundary: an internal call is a call on this, this is the target, and the target has no advice on it. Proved here by two identities printed side by side, with transaction active = false and an @Async method running on main. Move the method to another bean; use ObjectProvider of yourself when the methods genuinely belong together; keep exposeProxy for code you cannot change. And when an aspect is not firing, ask the proxy: Advised#getAdvisors prints every pointcut expression and advice method attached to the bean.

The next article stays inside the container but drops the proxy: Spring's own event mechanism — ApplicationEventPublisher, @EventListener, and @TransactionalEventListener for work that must happen only after the transaction commits.

Related Posts

[Advanced Spring Boot] Caching in Spring Boot: the Cache Abstraction, Caffeine, Redis and Invalidation

Spring Boot 4.1.1 caching on a product lookup with PostgreSQL and Redis: why @EnableCaching is still required, @Cacheable, @CachePut, @CacheEvict with allEntries and @Caching, SimpleKey and SpEL keys, condition, unless and cached Optional nulls, the LazyInitializationException a cached entity causes, Caffeine eviction and the cache.gets metrics, why Boot picks Redis over Caffeine, JDK serialization versus GenericJacksonJsonRedisSerializer for Jackson 3 and the type validator that rejects BigDecimal, the two serializer configurations that silently drop settings, latency with no cache, Caffeine and Redis, a cache stampede that sync = true does not stop on Redis, the evict-before-commit race and transactionAware(), stale reads across two instances, and what a stopped or hung Redis does to requests with and without a CacheErrorHandler.

[Advanced Spring Boot] Spring Transactions in Depth: Propagation, Isolation Levels and Rollback Rules

Spring transaction propagation, isolation and rollback rules on Spring Boot 4.1.1 with PostgreSQL: all seven propagations with JpaTransactionManager logs and backend pids, the REQUIRES_NEW connection pool deadlock with HikariCP timings, why NESTED fails with JpaTransactionManager and works with JdbcTransactionManager savepoints, non-repeatable reads, lost updates and write skew under each isolation level, SQLSTATE 40001 as CannotAcquireLockException, a correct retry around the transaction, readOnly at the JDBC, PostgreSQL and Hibernate layers, validateExistingTransaction, rollbackOn ALL_EXCEPTIONS and what really enforces @Transactional(timeout).

[Advanced Spring Boot] Spring Events: ApplicationEventPublisher, @EventListener and @TransactionalEventListener

Spring events on Spring Boot 4.1.1: publishing a record through ApplicationEventPublisher and the PayloadApplicationEvent it is wrapped in, proof that @EventListener runs on the caller thread inside the caller transaction, @Order and SpEL condition, a listener that returns another event, why a listener for a generic OrderEvent<Product> never fires without ResolvableTypeProvider, what a throwing listener does synchronously and under @Async, all four @TransactionalEventListener phases with the runs that show which fired, fallbackExecution, the AFTER_COMMIT write that silently loses a row and the REQUIRES_NEW that saves it, @Async listeners against an async applicationEventMulticaster, and where in-process events stop being a reliability mechanism.

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.