Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn’t Firing
An aspect that does not fire produces no error and no log line. A full reference for the ten pointcut designators Spring AOP supports (generated from real matches) and the fourteen it rejects, the difference between JDK and CGLIB proxies measured side by side, and a gallery of six aspects that silently do nothing. Includes two corrections to the reference documentation. Spring Boot 4.1.1, AspectJ 1.9.25.1, JDK 25.
Spring Boot 4.1.1 · Spring Framework 7.0.9 · AspectJ weaver 1.9.25.1 · JDK 25. The designator table below was generated by running one advice per designator and recording what each one matched.
An aspect that does not fire produces no error, no warning, and no log line. The annotation is in the file, the class is in the package, the application starts, and nothing happens. There is no stack trace to read because nothing failed — from the container’s point of view, nothing was ever requested.
That is why this article spends most of its length on failure. The happy path is four lines and every other write-up has it. The days people lose are in the six ways an aspect can be silently inert, and in the two places the reference documentation does not match what the code does.
Part
For you if
Covers
1 — Beginner
you have copied an aspect and it worked
what Spring AOP is, the Boot 4 starter rename, the five advice types
2 — Intermediate
you write pointcuts and guess at designators
all ten designators with real matches, the fourteen that are rejected, JDK vs CGLIB proxies
3 — Advanced
your aspect is not firing right now
the broken-aspect gallery, the three-question diagnosis, @Proxyable
Versions this was verified against. Spring Boot 4.1.1 (GA), Spring Framework 7.0.9, AspectJ weaver 1.9.25.1, Eclipse Temurin JDK 25.0.4.1 LTS. API shapes were checked by disassembling the jars with javap rather than by reading the documentation, which is how both corrections below were found.
Companion code: spring-boot-demo, directory spring-aop/. Six contract tests, four captured transcripts, regenerated by scripts/run-all.sh.
Part 1 — What Spring AOP is, and the dependency that changed
Spring AOP is a proxy mechanism that borrows AspectJ’s pointcut language. Both halves of that sentence predict a failure mode.
It is proxies
Spring does not modify your bytecode. When a bean matches a pointcut, the container puts a proxy in the registry in its place. Callers get the proxy; the proxy runs advice and forwards to the real object.
Everything else follows:
Only beans can be advised. An object created with new has no proxy.
Only calls through the proxy are intercepted. A call an object makes to itself is not.
Only overridable methods can be advised: not private, not final, not static.
Join points are method executions. Field access and constructor calls are not available, however the pointcut language may tempt you.
It borrows the language
aspectjweaver on the classpath supplies the pointcut parser. Spring uses it to decide which methods match, then does its own weaving with proxies. AspectJ’s weaver is never involved. That is why the supported designator list is a subset: the language can express call() and cflow(), and a proxy cannot implement them.
The starter was renamed in Spring Boot 4, and the old name fails resolution rather than warning.spring-boot-starter-aop has a last GA of 3.5.16 and a last publication of 4.0.0-M2. spring-boot-starter-aspectj starts at 4.0.0-M3. The contents are identical — spring-boot-starter, spring-aop, aspectjweaver — but the old artifact is no longer in the Boot BOM, so a dependency block copied from any pre-4 tutorial has no managed version and the build stops.
Worse is getting it half right: without aspectjweaver on the classpath at all, @Aspect classes are ordinary beans, no pointcut is ever parsed, and every aspect in the application silently matches nothing.
@Around is the only one that receives a ProceedingJoinPoint, and therefore the only one that can decide whether the target runs at all — retry it, cache around it, or replace its result.
Two rules for @Around, both of which fail quietly: it must return Object or a compatible type, and it must actually call proceed(). Forgetting proceed() turns every advised method into one that returns null and never runs its body — which presents as the target method being broken.
Part 2 — Designators, and which proxy you actually got
The ten Spring AOP supports
Designator
Matches on
Evaluated
Cost
execution(...)
method signature
statically
cheap
within(Type)
the declaring type
statically
cheap
this(Type)
the proxy‘s type
at runtime
per call
target(Type)
the target object‘s type
at runtime
per call
args(Types)
argument runtime types; can bind them
at runtime
per call
@target(Ann)
annotation on the executing object’s class
at runtime
per call
@args(Ann)
annotation on argument runtime types
at runtime
per call
@within(Ann)
annotation on the declaring type
statically
cheap
@annotation(Ann)
annotation on the method
statically
cheap
bean(name)
Spring bean name, wildcards allowed
—
cheap
bean(...) is Spring’s own and does not exist in AspectJ. The runtime group cannot be decided from the signature, so Spring checks on every candidate invocation — prefer the static equivalent when the distinction does not matter for you: @within over @target, within over this.
Running one advice per designator against the same small set of beans:
InventoryService.finalCheck was called during that run and appears nowhere, because it is final. Hold that thought for Part 3.
The fourteen it rejects, and the exception the docs get wrong
call, get, set, preinitialization, staticinitialization, initialization, handler, adviceexecution, withincode, cflow, cflowbelow, if, @this, @withincode.
All fourteen were fed to the parser. All fourteen were rejected, with the same exception:
Correction: this is not an IllegalArgumentException. The Spring reference documentation states that using an unsupported designator “results in an IllegalArgumentException being thrown”. Disassembling the class shows UnsupportedPointcutPrimitiveException extends java.lang.RuntimeException directly. A catch (IllegalArgumentException) written on the strength of that sentence will not catch it.
There is a second, sharper edge here: setExpression(...) only stores the string. Nothing is parsed until something asks the pointcut to match. An unsupported designator therefore fails at the first candidate invocation, not at startup — so a rarely-exercised aspect can ship broken and fail in production on a code path nobody hit in testing.
Which proxy you actually got
The Spring Framework’s own default is interface-based proxying when an interface exists. Spring Boot sets spring.aop.proxy-target-class=true — confirmed as the defaultValue in Boot’s own configuration metadata — so you get CGLIB either way unless you change it. On a bean that does implement an interface:
$ java -jar spring-aop-demo-1.0.0.jar
defaultOrderService CGLIB subclass class: DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
proxy is an instance of DefaultOrderService : True
$ java -jar spring-aop-demo-1.0.0.jar --spring.aop.proxy-target-class=false
defaultOrderService JDK dynamic proxy class: $Proxy62
interfaces : OrderService
proxy is an instance of DefaultOrderService : False
interfaceless(..) is not on OrderService, so under a JDK proxy it is invisible to advice — and casting the bean to DefaultOrderService throws ClassCastException.
This is why this() and target() are separate designators at all. this() tests the proxy; target() tests the object behind it. Under CGLIB they nearly always agree, which is precisely why the distinction only bites after somebody changes the proxy type — usually for an unrelated reason, in a different pull request, months later.
Part 3 — Six aspects that do not fire
Each of these was run. Every one reports that its advice did not execute, alongside a control proving the mechanism works when used correctly.
1. @Aspect without @Component
@Aspect is an AspectJ annotation. It tells Spring how to interpret a bean it already has; it does not create one. Without a stereotype or an @Bean method, the class is never instantiated and the pointcut is never registered.
The most common cause and the most invisible — there is nothing to warn about, because nothing was requested.
A package name matching no type is not an error; it is an empty match set, indistinguishable at runtime from an aspect that was never registered.
Fix: assert on it. Two lines in a unit test catches every typo of this kind permanently:
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.example.service.*.*(..))");
assertThat(pointcut.matches(Foo.class.getMethod("bar"), Foo.class)).isTrue();
3. A private method
Neither proxy strategy can override a private method, so neither can intercept it. Legal Java, no effect. IntelliJ warns; the compiler does not.
Note beanIsProxied: true. The bean is proxied. CGLIB subclasses it, and a final method is inherited rather than overridden, so calls go straight to the original. A final class fails loudly; a final method is silent.
5. Self-invocation
The expensive one, because the code looks correct and the annotation is right there.
public String entryPoint() {
return annotatedButCalledInternally(); // this. -> no proxy -> no advice
}
@Transactional // or any advised annotation
public String annotatedButCalledInternally() { ... }
Same method, same advice, same bean. Reached through the proxy it is advised; reached by this.inner() from another method of the same object it is not. Those two lines together are the whole proof, and they are worth more than any amount of explanation: the method is demonstrably advisable, and only the call path differed.
Fixes, best first: move the method to another bean (almost always right, and the resulting design is usually better — the @Transactional article shows the same failure costing real data); inject the bean into itself and call through that reference; or AopContext.currentProxy() with exposeProxy = true, which works and couples your code to Spring AOP.
6. An object created with new
No container, no proxy, no advice. Survives code review easily, because the annotation is right there on the method.
The three-question diagnosis
Almost every non-firing aspect is one of three things, and all three are one method call away.
// 1. Is it proxied at all? false = no pointcut matched, or it is not a bean.
AopUtils.isAopProxy(bean);
// 2. Which kind? A JDK proxy cannot advise a method absent from the interface.
AopUtils.isJdkDynamicProxy(bean);
AopUtils.isCglibProxy(bean);
// 3. Is your advice attached? Proxied + right kind + missing advice
// means the pointcut matched the BEAN but not the METHOD.
if (bean instanceof Advised advised) {
Arrays.stream(advised.getAdvisors())
.map(a -> a.getAdvice().getClass().getSimpleName())
.forEach(System.out::println);
}
Answering those in order takes a second. Guessing between them takes an afternoon.
Verified against the class file rather than the prose: org.springframework.context.annotation.Proxyable, targeting TYPE and METHOD, with ProxyType value() (DEFAULT, INTERFACES, TARGET_CLASS) and Class<?>[] interfaces(). Note the package — it lives in spring-context, not spring-aop, which is not where you would look for it.
The long tail
Advice ordering: why order within one aspect is not guaranteed, and why @Transactional defaults to Ordered.LOWEST_PRECEDENCE (2147483647, checked in the class file) so your logging advice sees the return before the commit: chapter 4
ExposeInvocationInterceptor, which heads every advisor chain and which you did not configure: chapter 4
CGLIB and Objenesis: why the target constructor is not called twice, and the --add-opens flag needed to proxy java.lang types on the module path: chapter 3
Named pointcuts, and why they are the biggest readability win available here: chapter 2
Should you write the aspect at all? Often not. AOP earns its place for genuinely cross-cutting concerns that would otherwise appear in a hundred methods — transactions, security, caching, metrics — and Spring already ships all four, written by people who have handled the edge cases.
A custom aspect is code that runs on methods whose authors cannot see it, and it fails by doing nothing. If the behaviour applies to five methods, call a method from those five. Write the aspect when the alternative is genuinely worse, and when you do, write the pointcut test in the same commit — because the failure mode of the thing you just built is silence.
Further reading
Companion project — runnable, with every transcript above under docs/output/
No Comments yet!