Skip to main content

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.
PartFor you ifCovers
1 — Beginneryou have copied an aspect and it workedwhat Spring AOP is, the Boot 4 starter rename, the five advice types
2 — Intermediateyou write pointcuts and guess at designatorsall ten designators with real matches, the fourteen that are rejected, JDK vs CGLIB proxies
3 — Advancedyour aspect is not firing right nowthe 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.
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>

The five advice types

AnnotationRunsCan stop the call?Can change the result?
@Beforebeforeonly by throwingno
@AfterReturningafter a normal returnnono (can read it)
@AfterThrowingafter an exceptionnono (can read it)
@Afterafter eithernono
@Aroundwraps the callyesyes
@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.
@Around("@annotation(Timed)")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();
    try {
        return pjp.proceed();
    } finally {
        record(pjp.getSignature(), System.nanoTime() - start);
    }
}
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

DesignatorMatches onEvaluatedCost
execution(...)method signaturestaticallycheap
within(Type)the declaring typestaticallycheap
this(Type)the proxy‘s typeat runtimeper call
target(Type)the target object‘s typeat runtimeper call
args(Types)argument runtime types; can bind themat runtimeper call
@target(Ann)annotation on the executing object’s classat runtimeper call
@args(Ann)annotation on argument runtime typesat runtimeper call
@within(Ann)annotation on the declaring typestaticallycheap
@annotation(Ann)annotation on the methodstaticallycheap
bean(name)Spring bean name, wildcards allowedcheap
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:
  execution (full signature)       -> DefaultOrderService.place(..)
  execution (wildcards)            -> DefaultOrderService.cancel(..)
  within                           -> InventoryService.reserve(..)
  this(OrderService)               -> place, cancel, interfaceless
  target(DefaultOrderService)      -> place, cancel, interfaceless
  args (bound: SKU-1/2)            -> DefaultOrderService.place(..)
  @target(Audited)                 -> DefaultOrderService.cancel(..)
  @args(Trackable)                 -> DefaultOrderService.interfaceless(..)
  @within(Audited)                 -> DefaultOrderService.place(..)
  @annotation(Marker)              -> DefaultOrderService.place(..)
  bean(inventoryService)           -> InventoryService.reserve(..)
  bean(*OrderService)              -> place, cancel, interfaceless
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:
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
Pointcut expression 'call(* com.ankurm.aop.service.OrderService.place(..))'
contains unsupported pointcut primitive 'call'
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
Same aspects, same beans, different result:
CGLIB:  this(OrderService)  -> place, cancel, interfaceless
JDK:    this(OrderService)  -> place, cancel
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.
caller proxy advice runs here then forwards target object outer() advised inner() NOT advised this.inner() The proxy wraps the OBJECT, not its methods. A call the object makes to itself never leaves it, so the proxy is not in that call path and no advice runs — even though inner() is annotated, and even though calling inner() directly through the proxy does advise it. This is the same mechanism behind @Transactional and @Cacheable doing nothing on internal calls.

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.

2. A pointcut that matches nothing

@Before("execution(* com.ankurm.aop.services.*.*(..))")   // "services", plural
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.

4. A final method

"4-final-method": {
    "beanIsProxied": true,
    "proxyKind": "CGLIB subclass",
    "adviceFired": false
}
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() { ... }
"5-self-invocation": {
    "beanIsProxied": true,
    "innerAdvisedWhenCalledFromOuter": false,
    "innerAdvisedWhenCalledDirectly": true
}
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.

@Proxyable, new in Spring 7.0

Per-bean control instead of one global switch:
@Proxyable(ProxyType.INTERFACES)
@Bean
MyService myService() { ... }
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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.