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.
![]()
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:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&dependencies=aop"{"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:
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:
+--- 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.1Three 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:
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:
| Property | Type | Default | What it does |
|---|---|---|---|
spring.aop.auto | Boolean | true | adds @EnableAspectJAutoProxy |
spring.aop.proxy-target-class | Boolean | true | CGLIB 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:
| \--- org.springframework:spring-aspects:7.0.9
| \--- org.aspectj:aspectjweaver:1.9.25 -> 1.9.25.1The lab application for this article was rebuilt with the AspectJ starter line deleted, and the condition report printed by --debug was unchanged:
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:
error: package org.aspectj.lang does not exist
import org.aspectj.lang.ProceedingJoinPoint;
^
error: cannot find symbol
@Aspect
^
symbol: class AspectSo: 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:
Trace.note("auto-proxy creator: "
+ context.getBean("org.springframework.aop.config.internalAutoProxyCreator").getClass().getName()); auto-proxy creator: org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreatorIn 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:
package com.example.demo.pricing;
import java.math.BigDecimal;
public interface PriceService {
BigDecimal quote(String sku, int quantity);
String name();
}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:
bean class com.example.demo.pricing.ListPriceService$$SpringCGLIB$$0
AopUtils.isAopProxy true
isJdkDynamicProxy false
isCglibProxy true
ultimateTargetClass com.example.demo.pricing.ListPriceServiceThe 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:
server.port=8203
spring.aop.proxy-target-class=false 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:
| Call | Answers |
|---|---|
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 |

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:
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:
@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:
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.*(..)):
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:
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() -> privateThat output is the table this section exists for:
| Target | Overridden by the proxy | Advice runs | How it fails |
|---|---|---|---|
public method | yes | yes | — |
protected method | yes | yes | — |
| package-private method | yes | yes | — (the subclass is generated in the same package and class loader) |
final method | no | no | silently, and worse than silently — see below |
private method | no | no | silently; it is self-invocation by definition |
final class | — | — | loudly: 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:
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:
== 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 nullThe 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.
cast to ListPriceService worked, describe() next
describe() threw java.lang.NullPointerException: Cannot invoke "java.util.Map.size()" because "this.catalog" is nulldescribe() 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:
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.⚠️
finalon a method of a Spring bean is not a safety feature. It either loses the advice or, if the method reads a field, produces aNullPointerExceptionin 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.
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:
@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);
}
}== 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| Designator | Selects | Notes |
|---|---|---|
execution(…) | method executions by signature | the 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 package | cheap and coarse; the usual choice for a whole layer |
@annotation(…) | methods carrying an annotation | how you build your own @Audited, @Timed, @RateLimited |
bean(…) | methods on beans whose name matches | Spring-only, not AspectJ; supports * so bean(*Repository) works |
args(…) | calls whose runtime arguments match | also 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:
== 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 = OKand for the same call when the target throws:
== 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
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.
| Advice | Signature it usually takes | Can it change the outcome |
|---|---|---|
@Around | Object around(ProceedingJoinPoint pjp) throws Throwable | yes — arguments, return value, exceptions, and whether the target runs at all |
@Before | void before(JoinPoint jp) | only by throwing |
@AfterReturning | void afterReturning(Object result) | no |
@AfterThrowing | void afterThrowing(Throwable ex) | no |
@After | void 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:
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";
}
}
}== 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 typedObject; returning something the caller cannot cast produces aClassCastExceptionat 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:
@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:
== 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) exitThe 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.
@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:
== 8. self-invocation
the bean the caller holds: ReportService$$SpringCGLIB$$1@c247b02
--- viaThis() ---
viaThis(): this = ReportService@272c5abd
generate(1): transaction active = falseThe 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.

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.
/** 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);
} --- 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 = trueBoth 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:
@Configuration
@EnableAspectJAutoProxy(exposeProxy = true)
public class ExposeProxyConfig {
}Without that annotation the call fails, loudly, which is at least honest:
--- 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:
--- viaAopContext() ---
viaAopContext(): this = ReportService@7fcbc336
[ReportAspect] ADVICE RAN for generate
generate(4): transaction active = trueThe 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:
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);
}
} --- another bean calls it ---
ReportBatch.run(): injected ReportService = ReportService$$SpringCGLIB$$1@c247b02
[ReportAspect] ADVICE RAN for generate
generate(5): transaction active = true| Fix | What it costs | When it is right |
|---|---|---|
| another bean | one class | the default — the split usually improves the design anyway |
ObjectProvider<Self> | one field, one getObject() call | a genuine loop over an annotated method inside one cohesive service |
@Lazy self-injection | one field, plus a second proxy | no advantage over ObjectProvider; prefer the explicit one |
AopContext.currentProxy() | a context-wide ThreadLocal on every call, a cast, thread affinity | legacy 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:
--- @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-2Through 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:
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/calland with spring.aop.proxy-target-class=false:
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/callRead 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:
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);
}
} 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=SINGLETONEach 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:
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:
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());
}
} --- 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:
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.