1
0

Three new article modules: configuration binding, profiles and config data, Spring AOP

configuration-properties/  @ConfigurationProperties vs @Value on Spring Boot 4.1.1.
  The relaxed-binding matrix is generated by binding each spelling rather than
  transcribed, and re-checked against real processes -- the in-process probe was
  wrong twice before it was right. Records the three findings that came out of it:
  @Value does get relaxed resolution inside Spring Boot (Boot attaches
  ConfigurationPropertySources), the configuration processor silently stops
  generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is
  not what makes nested constraints run.

profiles-and-config/       Precedence, profiles, spring.config.import and config trees.
  /precedence reports every source holding a property in rank order with file and
  line, which turns "my profile file had no effect" into a two-line answer. Also
  pins the counterintuitive one: an imported file outranks the file that imported it.

spring-aop/                Designators, proxy types, and aspects that do not fire.
  One advice per supported designator so the reference table is generated from real
  matches; all fourteen unsupported designators fed to the parser. Two corrections to
  the reference documentation: unsupported designators throw
  UnsupportedPointcutPrimitiveException (extends RuntimeException, not
  IllegalArgumentException), and spring-boot-starter-aop was renamed to
  spring-boot-starter-aspectj in Boot 4.

19 contract tests across the three modules, 15 captured transcripts, all regenerated
by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9,
JDK 25.0.4.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
2026-09-08 16:36:17 +00:00
parent 958b401f0f
commit 86246dc860
107 changed files with 5075 additions and 0 deletions

67
spring-aop/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Spring AOP: designators, proxies, and aspects that do not fire
Companion project for [**Spring AOP Explained**](https://ankurm.com/) on ankurm.com.
Two halves. One set of aspects that work — one advice per pointcut designator, so the reference
table is generated from real matches. One set that does not fire, each for a different reason,
each paired with its fix.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| AspectJ weaver | 1.9.25.1 |
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
## Read this before copying a dependency block
In Spring Boot 3 the starter was `spring-boot-starter-aop`. In Spring Boot 4 it is
**`spring-boot-starter-aspectj`**. The old artifact's last publication is `4.0.0-M2` and its
last GA is `3.5.16`, so a pre-4 dependency block fails resolution with a missing-version error.
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/run-all.sh # regenerate every transcript in docs/output/
mvn test # 6 contract tests
```
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /aop/proxies` | per bean: proxied or not, which kind, and the advisor list |
| `GET /aop/designators` | exercises every advised method, reports what each designator matched |
| `GET /aop/parser` | feeds supported and unsupported expressions to the pointcut parser |
| `GET /aop/broken` | runs the failure gallery and reports that nothing fired |
## Documentation
1. [What Spring AOP actually is](docs/01-what-spring-aop-is.md)
2. [The designator reference](docs/02-designators.md)
3. [JDK dynamic proxies and CGLIB](docs/03-proxy-types.md)
4. [Advice types and ordering](docs/04-advice-types.md)
5. [Six aspects that do not fire](docs/05-broken-aspect-gallery.md)
6. [Diagnosing a silent aspect](docs/06-diagnosing-a-silent-aspect.md)
## Captured output
| File | Produced by |
|---|---|
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
| [`01-designators.txt`](docs/output/01-designators.txt) | `scripts/demo-designators.sh` |
| [`02-proxy-types.txt`](docs/output/02-proxy-types.txt) | `scripts/demo-proxy-types.sh` |
| [`03-broken-gallery.txt`](docs/output/03-broken-gallery.txt) | `scripts/demo-broken-gallery.sh` |
## Two corrections to the reference documentation
- Unsupported designators throw **`UnsupportedPointcutPrimitiveException`**, which extends
`RuntimeException` directly — not `IllegalArgumentException` as the docs state. Catching the
documented type will not catch it.
- The framework default is interface-based proxying, but **Spring Boot sets
`proxy-target-class=true`**, so you get CGLIB even for beans that implement interfaces. That
changes which designators match; `scripts/demo-proxy-types.sh` shows both.

View File

@@ -0,0 +1,51 @@
[Index](../README.md) &middot; [Designators &rarr;](02-designators.md)
# 1. What Spring AOP actually is
Spring AOP is a **proxy** mechanism that borrows AspectJ's **pointcut language**. Both halves
of that sentence explain 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 bean registry in its place. Callers get the proxy; the proxy runs advice and then
forwards to the real object.
Everything that follows from this:
- Only **beans** can be advised. An object created with `new` has no proxy.
- Only calls **through the proxy** are intercepted. A call the 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, constructor calls and exception handlers
are not available, no matter what the pointcut language allows you to write.
## It borrows the pointcut language
`aspectjweaver` on the classpath supplies the pointcut *parser*. Spring uses it to decide which
methods match and then does its own weaving with proxies. AspectJ's own weaver is not involved.
This is why the designator list is a subset: the language can express `call()` and `cflow()`,
and a proxy cannot implement them. See [chapter 2](02-designators.md).
## The Spring Boot 4 starter rename
In Spring Boot 3 the dependency was `spring-boot-starter-aop`. In Spring Boot 4 it is
**`spring-boot-starter-aspectj`**.
```
spring-boot-starter-aop last published 4.0.0-M2 (last GA: 3.5.16)
spring-boot-starter-aspectj first published 4.0.0-M3
```
The contents are unchanged: `spring-boot-starter`, `spring-aop`, `aspectjweaver`. But the old
artifact is no longer in the Boot BOM, so copying a dependency block out of any pre-4 tutorial
fails resolution with a missing-version error rather than a helpful message.
Without the starter, `@Aspect` classes are ordinary beans, no pointcut is ever parsed, and
every aspect in the application silently matches nothing.
## When to use something else
If you need to advise field access, constructors, or calls between objects you do not own, you
need real AspectJ weaving (compile-time or load-time), not Spring AOP. If you need to advise
one internal call, you need to refactor — see [chapter 5](05-broken-aspect-gallery.md).

View File

@@ -0,0 +1,86 @@
[&larr; What Spring AOP is](01-what-spring-aop-is.md) &middot; [Index](../README.md) &middot; [Proxy types &rarr;](03-proxy-types.md)
# 2. The designator reference
Generated by [`DesignatorAspect`](../src/main/java/com/ankurm/aop/aspect/DesignatorAspect.java)
and [`PointcutParserEndpoint`](../src/main/java/com/ankurm/aop/web/PointcutParserEndpoint.java).
Transcript: [`01-designators.txt`](output/01-designators.txt).
## Supported
| 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 | 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; it does not exist in AspectJ.
The runtime group cannot be decided from the signature alone, so Spring must check on every
candidate invocation. Prefer the static equivalent where one exists: `@within` instead of
`@target`, `within` instead of `this`, when the distinction does not matter.
## What each one actually matched here
```
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 and appears nowhere: it is `final`, so no proxy could
override it. That is [failure 4](05-broken-aspect-gallery.md).
## Not supported
`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:
```
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
Pointcut expression 'call(* ...place(..))' contains unsupported pointcut primitive 'call'
```
**The reference documentation says these throw `IllegalArgumentException`. They do not.**
`UnsupportedPointcutPrimitiveException extends RuntimeException` directly — checked with
`javap`, pinned by `AopContractTests.unsupportedDesignatorExceptionType`. A
`catch (IllegalArgumentException)` will not catch it.
## When the failure happens
`setExpression(...)` only stores the string. The expression is not parsed or validated until
something asks it to match. An unsupported designator therefore fails at the first candidate
invocation, not at startup — so a rarely-exercised aspect can ship broken.
## Combining and naming
`&&`, `||` and `!` compose designators. Name the result rather than repeating it:
```java
@Pointcut("within(com.ankurm.aop.service..*)")
public void inServiceLayer() {}
@Before("this(OrderService) && inServiceLayer()")
public void advice(JoinPoint jp) { }
```
A named pointcut is referenced by its method name and is the single biggest readability win
available here.

View File

@@ -0,0 +1,78 @@
[&larr; Designators](02-designators.md) &middot; [Index](../README.md) &middot; [Advice types &rarr;](04-advice-types.md)
# 3. JDK dynamic proxies and CGLIB
Transcript: [`02-proxy-types.txt`](output/02-proxy-types.txt).
| | JDK dynamic proxy | CGLIB |
|---|---|---|
| Built by | `java.lang.reflect.Proxy` | subclassing the target |
| Requires | at least one interface | a non-final class |
| Proxy is an instance of | the interfaces only | the target class |
| Can advise | public interface methods | public, protected, package-private |
| Cannot advise | anything not on the interface | `final`, `private`, `static` |
| Class name | `$Proxy62` | `Foo$$SpringCGLIB$$0` |
## Spring Boot chooses CGLIB
The Spring Framework's own default is interface-based when an interface exists. **Spring Boot
sets `spring.aop.proxy-target-class=true`**, so you get CGLIB either way unless you change it.
Measured, on a bean that *does* implement an interface:
```
defaultOrderService CGLIB subclass class: DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
proxy is an instance of DefaultOrderService : True
```
And with `--spring.aop.proxy-target-class=false`:
```
defaultOrderService JDK dynamic proxy class: $Proxy62
interfaces : OrderService
proxy is an instance of DefaultOrderService : False
```
## What changes when you switch
Same aspects, same beans:
```
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()` exist as separate designators. `this()` tests the proxy;
`target()` tests the object behind it. Under CGLIB they nearly always agree, which is exactly
why the distinction only ever bites *after* somebody changes the proxy type.
## CGLIB constraints worth knowing
- A **final class** cannot be proxied at all — this one fails loudly.
- A **final method** is silently not advised. The bean is still a proxy; the method just is not
overridden. This is the quiet one.
- **Private** methods are never advised by either strategy.
- The target's constructor is **not** called twice: Spring creates the proxy instance through
Objenesis. On a JVM that forbids constructor bypassing you may see double invocation and a
debug log line about it.
- On the module path, classes in `java.lang` cannot be proxied without
`--add-opens=java.base/java.lang=ALL-UNNAMED`.
## `@Proxyable`, new in Spring 7.0
Per-bean control, rather than one global switch:
```java
@Proxyable(ProxyType.INTERFACES)
@Bean
MyService myService() { ... }
```
Verified against the class file rather than the docs:
`org.springframework.context.annotation.Proxyable`, targets `TYPE` and `METHOD`, with
`ProxyType value()` (`DEFAULT`, `INTERFACES`, `TARGET_CLASS`) and `Class<?>[] interfaces()`.
Note the package — it is in `spring-context`, not `spring-aop`.

View File

@@ -0,0 +1,56 @@
[&larr; Proxy types](03-proxy-types.md) &middot; [Index](../README.md) &middot; [Broken aspect gallery &rarr;](05-broken-aspect-gallery.md)
# 4. Advice types and ordering
## The five
| Annotation | Runs | Can it stop the call? | Can it change the result? |
|---|---|---|---|
| `@Before` | before | only by throwing | no |
| `@AfterReturning` | after a normal return | no | no (can read it) |
| `@AfterThrowing` | after an exception | no | no (can read it) |
| `@After` | after either | no | no |
| `@Around` | wraps | yes | yes |
`@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.
```java
@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`: it must return `Object` (or a compatible type) and it must actually
call `proceed()`. Forgetting `proceed()` silently turns every advised method into a method that
returns `null` and never runs — a failure that looks like the target method being broken.
## Ordering
Within one aspect, advice order for the same join point is **not** guaranteed and is not the
source order. If two pieces of advice in one aspect must be ordered, split them into two
aspects.
Between aspects, `@Order` or `Ordered` decides. Lower value = higher precedence = outermost.
Advice nests: the outermost aspect's `@Before` runs first and its `@After` runs last.
```
@Order(1) Tx : before ... [ @Order(2) Logging : before ... target ... after ] ... after
```
This matters when combining transactions with anything that must be inside or outside them.
`@Transactional` is ordered at `Ordered.LOWEST_PRECEDENCE` by default, so almost everything
else wraps *outside* it — meaning your logging advice sees the method return before the
transaction commits, and therefore before a commit failure has happened.
## `ExposeInvocationInterceptor`
You will see this at the head of every advisor chain in
[`02-proxy-types.txt`](output/02-proxy-types.txt). Spring adds it automatically so that
`AopContext.currentProxy()` and argument binding work. It is not something you configured.

View File

@@ -0,0 +1,84 @@
[&larr; Advice types](04-advice-types.md) &middot; [Index](../README.md) &middot; [Diagnosing &rarr;](06-diagnosing-a-silent-aspect.md)
# 5. Six aspects that do not fire
Sources: [`BrokenAspects`](../src/main/java/com/ankurm/aop/broken/BrokenAspects.java),
[`SelfInvokingService`](../src/main/java/com/ankurm/aop/broken/SelfInvokingService.java),
[`NewedUpService`](../src/main/java/com/ankurm/aop/broken/NewedUpService.java).
Transcript: [`03-broken-gallery.txt`](output/03-broken-gallery.txt).
None of these warn. None fail at startup. All of them look correct in review.
## 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 — from the container's point of view nothing was
ever requested, so there is nothing to warn about.
**Fix:** add `@Component`.
## 2. A pointcut that matches nothing
```java
@Before("execution(* com.ankurm.aop.services.*.*(..))") // "services", plural
```
A package name matching no type is not an error; it is an empty match set. At runtime this is
indistinguishable from an aspect that was never registered.
**Fix:** assert on it. `AspectJExpressionPointcut#matches(Method, Class)` in a unit test is two
lines and catches every typo permanently.
## 3. A private method
Neither proxy strategy can override a private method, so neither can intercept it. The
annotation is legal and inert.
**Fix:** make it at least package-private *and* call it from outside the object — visibility
alone is not enough if the call is internal, which brings you to number 5.
## 4. A final method
CGLIB proxies by subclassing. 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.
Note `beanIsProxied: true` in the transcript. The bean is proxied. This one method is not.
**Fix:** remove `final`, or proxy by interface.
## 5. Self-invocation
The expensive one, because the code looks right and the annotation is visible.
```json
"5-self-invocation": {
"beanIsProxied": true,
"innerAdvisedWhenCalledFromOuter": false,
"innerAdvisedWhenCalledDirectly": true
}
```
Same method, same advice. Called through the proxy it is advised; reached by `this.inner()`
from another method of the same object it is not, because the proxy is not in that call path.
This is the same mechanism that makes `@Transactional` and `@Cacheable` silently do nothing on
internal calls. Learning it once here saves learning it three times.
**Fixes, best first:**
1. Move the method to another bean. This is almost always the right answer, and the resulting
design is usually better anyway.
2. Inject the bean into itself and call through that reference.
3. `AopContext.currentProxy()` with `exposeProxy = true`. Works; couples your code to Spring
AOP and makes the class aware it is proxied.
## 6. An object created with `new`
Spring AOP advises beans. An instance built by a factory, a helper or a test has no proxy and
never will.
**Fix:** get it from the container. If it genuinely must be constructed by hand and still
advised, that is what AspectJ load-time weaving is for.

View File

@@ -0,0 +1,75 @@
[&larr; Broken aspect gallery](05-broken-aspect-gallery.md) &middot; [Index](../README.md)
# 6. Diagnosing a silent aspect
Endpoint: [`AopDiagnosticsEndpoint`](../src/main/java/com/ankurm/aop/web/AopDiagnosticsEndpoint.java).
Almost every non-firing aspect is one of three things, and all three are visible in one place.
## The three questions, in order
**1. Is the bean proxied at all?**
```java
AopUtils.isAopProxy(bean)
```
`false` means no pointcut matched this bean, or it is not a bean. Stop here and check the
pointcut and the registration.
**2. Which kind of proxy?**
```java
AopUtils.isJdkDynamicProxy(bean) // interfaces only
AopUtils.isCglibProxy(bean) // subclass
```
If it is a JDK proxy and your method is not on an interface, that is your answer.
**3. Is your advice in the advisor list?**
```java
if (bean instanceof Advised advised) {
Arrays.stream(advised.getAdvisors())
.map(a -> a.getAdvice().getClass().getSimpleName())
.forEach(System.out::println);
}
```
Proxied, right kind, and your advice missing means the pointcut matched the *bean* but not the
*method*.
## Output
```
defaultOrderService CGLIB subclass target=DefaultOrderService
class : DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
advisors : 11
```
## Testing a pointcut without an application
The fastest check of all, and it belongs in a test rather than in a diagnostic endpoint:
```java
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.example.service.*.*(..))");
assertThat(pointcut.matches(Foo.class.getMethod("bar"), Foo.class)).isTrue();
```
Remember that `setExpression` does not parse; the parse happens on first `matches`. So this is
also how you find out that a designator is unsupported before production does.
## Logging
```
logging.level.org.springframework.aop=DEBUG
```
reports proxy creation per bean. Verbose, but it answers question 1 for every bean at once.
## Delete the endpoint before shipping
It reports internal wiring. If you want it permanently, put it behind the management port and
authentication.

View File

@@ -0,0 +1,11 @@
== versions ==
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
spring-boot-starter-parent: 4.1.1
aspectjweaver: 1.9.25.1
== the Boot 4 starter rename ==
spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)
spring-boot-starter-aspectj first published: 4.0.0-M3

View File

@@ -0,0 +1,59 @@
== which designator matched which join point ==
Five methods are called once each: OrderService.place, OrderService.cancel,
InventoryService.reserve, InventoryService.finalCheck and
DefaultOrderService.interfaceless.
orderService proxy kind : CGLIB subclass
proxy is an instance of DefaultOrderService : True
args (bound: SKU-1/2) -> DefaultOrderService.place(..)
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
@within(Audited) -> DefaultOrderService.place(..)
execution (full signature) -> DefaultOrderService.place(..)
@annotation(Marker) -> DefaultOrderService.place(..)
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
execution (wildcards) -> DefaultOrderService.cancel(..)
@target(Audited) -> DefaultOrderService.cancel(..)
bean(inventoryService) -> InventoryService.reserve(..)
within -> InventoryService.reserve(..)
@args(Trackable) -> DefaultOrderService.interfaceless(..)
== what the parser accepts and refuses ==
supported (all parsed and evaluated):
execution(* com.ankurm.aop.service.OrderService.place(..)) OK
within(com.ankurm.aop.service..*) OK
this(com.ankurm.aop.service.OrderService) OK
target(com.ankurm.aop.service.DefaultOrderService) OK
args(String, int) OK
@target(com.ankurm.aop.service.Audited) OK
@args(com.ankurm.aop.service.Trackable) OK
@within(com.ankurm.aop.service.Audited) OK
@annotation(com.ankurm.aop.service.Marker) OK
bean(defaultOrderService) OK
unsupported in Spring AOP:
call(* com.ankurm.aop.service.OrderService.place(. rejected
get(* com.ankurm.aop.service.*.*) rejected
set(* com.ankurm.aop.service.*.*) rejected
initialization(com.ankurm.aop.service.*.new(..)) rejected
staticinitialization(com.ankurm.aop.service.*) rejected
preinitialization(com.ankurm.aop.service.*.new(..) rejected
handler(java.lang.Exception) rejected
adviceexecution() rejected
withincode(* com.ankurm.aop.service.*.*(..)) rejected
cflow(execution(* com.ankurm.aop.service.*.*(..))) rejected
cflowbelow(execution(* com.ankurm.aop.service.*.*( rejected
if() rejected
@this(com.ankurm.aop.service.Audited) rejected
@withincode(com.ankurm.aop.service.Marker) rejected
the exception, in full:
org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException
Pointcut expression 'call(* com.ankurm.aop.service.OrderService.place(..))' contains unsupported pointcut primitive 'call'
The reference documentation says these produce an IllegalArgumentException. They do
not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a
catch of IllegalArgumentException will not catch it.

View File

@@ -0,0 +1,53 @@
== Spring Boot default: spring.aop.proxy-target-class=true ==
$ java -jar target/spring-aop-demo-1.0.0.jar
defaultOrderService CGLIB subclass target=DefaultOrderService
class : DefaultOrderService$$SpringCGLIB$$0
interfaces : (none)
advisors : 11
inventoryService CGLIB subclass target=InventoryService
class : InventoryService$$SpringCGLIB$$0
interfaces : (none)
advisors : 6
selfInvokingService CGLIB subclass target=SelfInvokingService
class : SelfInvokingService$$SpringCGLIB$$0
interfaces : (none)
advisors : 2
proxy is an instance of DefaultOrderService : True
this(OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
target(DefaultOrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
bean(*OrderService) -> DefaultOrderService.place(..), DefaultOrderService.cancel(..), DefaultOrderService.interfaceless(..)
== framework default restored: spring.aop.proxy-target-class=false ==
$ java -jar target/spring-aop-demo-1.0.0.jar --spring.aop.proxy-target-class=false
defaultOrderService JDK dynamic proxy target=DefaultOrderService
class : $Proxy62
interfaces : OrderService
advisors : 11
inventoryService CGLIB subclass target=InventoryService
class : InventoryService$$SpringCGLIB$$0
interfaces : (none)
advisors : 6
selfInvokingService CGLIB subclass target=SelfInvokingService
class : SelfInvokingService$$SpringCGLIB$$0
interfaces : (none)
advisors : 2
proxy is an instance of DefaultOrderService : False
this(OrderService) -> OrderService.place(..), OrderService.cancel(..)
target(DefaultOrderService) -> OrderService.place(..), OrderService.cancel(..)
bean(*OrderService) -> OrderService.place(..), OrderService.cancel(..)
Same aspects, same beans, different proxy strategy:
- With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of
the implementation class and methods that are not on the interface are advised.
- With a JDK proxy the proxy implements OrderService only. It is NOT an instance of
DefaultOrderService, casting to that class throws ClassCastException, and any
method absent from the interface is invisible to advice.
This is why this() and target() differ. this() tests the proxy; target() tests the
object behind it. Under CGLIB they usually agree, which is exactly why the
distinction only bites after somebody switches the proxy type.

View File

@@ -0,0 +1,37 @@
== aspects that do not fire ==
{
"5-self-invocation": {
"beanIsProxied": true,
"result": "outer -> inner",
"innerAdvisedWhenCalledFromOuter": false,
"innerAdvisedWhenCalledDirectly": true
},
"6-created-with-new": {
"isProxy": false,
"adviceFired": false
},
"4-final-method": {
"beanIsProxied": true,
"proxyKind": "CGLIB subclass",
"adviceFired": false
},
"1-aspect-without-component-fired": false,
"2-pointcut-typo-fired": false,
"3-private-method-fired": false,
"note": "every value above should be false except the two that prove the method is advisable when reached through the proxy"
}
Reading it:
1 @Aspect without @Component - the class is never instantiated, so the pointcut
is never registered. No warning is produced.
2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and
matches nothing. An empty match set is not an error.
3 private method - cannot be overridden, so cannot be intercepted.
4 final method - CGLIB subclasses; a final method is inherited, not
overridden. Note beanIsProxied is still true.
5 self-invocation - innerAdvisedWhenCalledFromOuter is false and
innerAdvisedWhenCalledDirectly is true. Same method,
same advice: only the call path differs.
6 created with new - no container, no proxy, no advice.

60
spring-aop/pom.xml Normal file
View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>spring-aop-demo</artifactId>
<version>1.0.0</version>
<name>spring-aop-demo</name>
<description>Spring AOP: pointcut designators, proxy types, and why aspects do not fire</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- NOTE THE NAME. In Spring Boot 3 this was spring-boot-starter-aop. That artifact was
renamed to spring-boot-starter-aspectj during the Boot 4 milestones: its last
published version is 4.0.0-M2 and its last GA is 3.5.16, while
spring-boot-starter-aspectj starts at 4.0.0-M3. Copying an AOP dependency block out
of any pre-4 tutorial therefore fails resolution, because the old name is no longer
in the Boot BOM and therefore has no managed version.
Contents are unchanged: spring-boot-starter, spring-aop and aspectjweaver. It is
aspectjweaver that supplies the pointcut PARSER: Spring AOP does its own weaving
at runtime with proxies, and borrows only the expression language from AspectJ.
Without it, @Aspect classes are ordinary beans and every pointcut here silently
matches nothing. See docs/01-what-spring-aop-is.md. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# The broken-aspect gallery: six aspects that do not fire, and why.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== aspects that do not fire =="
echo
start_app > /dev/null
curl -s "http://127.0.0.1:${APP_PORT}/aop/broken" | python3 -m json.tool
echo
echo "Reading it:"
echo
echo " 1 @Aspect without @Component - the class is never instantiated, so the pointcut"
echo " is never registered. No warning is produced."
echo " 2 pointcut typo - 'com.ankurm.aop.services' (plural) parses fine and"
echo " matches nothing. An empty match set is not an error."
echo " 3 private method - cannot be overridden, so cannot be intercepted."
echo " 4 final method - CGLIB subclasses; a final method is inherited, not"
echo " overridden. Note beanIsProxied is still true."
echo " 5 self-invocation - innerAdvisedWhenCalledFromOuter is false and"
echo " innerAdvisedWhenCalledDirectly is true. Same method,"
echo " same advice: only the call path differs."
echo " 6 created with new - no container, no proxy, no advice."
stop_app
} > docs/output/03-broken-gallery.txt 2>&1
cat docs/output/03-broken-gallery.txt

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Every pointcut designator Spring AOP supports, with what it actually matched.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== which designator matched which join point =="
echo
echo "Five methods are called once each: OrderService.place, OrderService.cancel,"
echo "InventoryService.reserve, InventoryService.finalCheck and"
echo "DefaultOrderService.interfaceless."
echo
start_app > /dev/null
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" orderService proxy kind :", d["orderServiceProxyKind"])
print(" proxy is an instance of DefaultOrderService :",
d["orderServiceIsDefaultOrderServiceInstance"])
print()
for k,v in d["matches"].items():
print(" %-32s -> %s" % (k, ", ".join(v)))'
echo
echo "== what the parser accepts and refuses =="
echo
curl -s "http://127.0.0.1:${APP_PORT}/aop/parser" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" supported (all parsed and evaluated):")
for r in d["supported"]:
print(" %-52s %s" % (r["expression"], "OK" if r["accepted"] else "REJECTED"))
print()
print(" unsupported in Spring AOP:")
for r in d["unsupported"]:
print(" %-52s %s" % (r["expression"][:50], "accepted!" if r["accepted"] else "rejected"))
print()
first=[r for r in d["unsupported"] if not r["accepted"]][0]
print(" the exception, in full:")
print(" " + first["exception"])
print(" " + first["message"])'
echo
echo "The reference documentation says these produce an IllegalArgumentException. They do"
echo "not: UnsupportedPointcutPrimitiveException extends RuntimeException directly, so a"
echo "catch of IllegalArgumentException will not catch it."
stop_app
} > docs/output/01-designators.txt 2>&1
cat docs/output/01-designators.txt

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# JDK dynamic proxies versus CGLIB, and what changes when you switch.
#
# Spring Boot sets spring.aop.proxy-target-class=true by default, so beans are proxied by
# CGLIB even when they implement an interface. Setting it to false restores the framework's
# own default and changes which designators match -- the same aspects, different results.
set -euo pipefail
set +m
cd "$(dirname "$0")/.."
source scripts/env.sh
snapshot() {
curl -s "http://127.0.0.1:${APP_PORT}/aop/proxies" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %-22s %-18s target=%s" % (r["bean"], r["proxyKind"], r["targetClass"].split(".")[-1]))
print(" class : %s" % r["class"].split(".")[-1])
print(" interfaces : %s" % (", ".join(r.get("proxiedInterfaces") or []) or "(none)"))
print(" advisors : %d" % r.get("advisorCount", 0))'
echo
curl -s "http://127.0.0.1:${APP_PORT}/aop/designators" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(" proxy is an instance of DefaultOrderService :",
d["orderServiceIsDefaultOrderServiceInstance"])
for k in ("this(OrderService)","target(DefaultOrderService)","bean(*OrderService)"):
print(" %-30s -> %s" % (k, ", ".join(d["matches"].get(k, ["(no match)"]))))'
}
{
echo "== Spring Boot default: spring.aop.proxy-target-class=true =="
echo "\$ java -jar $JAR"
echo
start_app > /dev/null
snapshot
echo
echo "== framework default restored: spring.aop.proxy-target-class=false =="
echo "\$ java -jar $JAR --spring.aop.proxy-target-class=false"
echo
start_app --spring.aop.proxy-target-class=false > /dev/null
snapshot
echo
echo "Same aspects, same beans, different proxy strategy:"
echo
echo " - With CGLIB the proxy is a SUBCLASS of DefaultOrderService, so it is an instance of"
echo " the implementation class and methods that are not on the interface are advised."
echo " - With a JDK proxy the proxy implements OrderService only. It is NOT an instance of"
echo " DefaultOrderService, casting to that class throws ClassCastException, and any"
echo " method absent from the interface is invisible to advice."
echo
echo "This is why this() and target() differ. this() tests the proxy; target() tests the"
echo "object behind it. Under CGLIB they usually agree, which is exactly why the"
echo "distinction only bites after somebody switches the proxy type."
stop_app
} > docs/output/02-proxy-types.txt 2>&1
cat docs/output/02-proxy-types.txt

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
{
echo "== versions =="
java -version 2>&1 | clean
echo
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
echo "aspectjweaver: $(find ~/.m2/repository -name 'aspectjweaver-*.jar' | sed 's/.*aspectjweaver-//;s/\.jar//' | sort | tail -1)"
echo
echo "== the Boot 4 starter rename =="
echo "spring-boot-starter-aop last published: 4.0.0-M2 (last GA 3.5.16)"
echo "spring-boot-starter-aspectj first published: 4.0.0-M3"
} > docs/output/00-versions.txt 2>&1
cat docs/output/00-versions.txt

61
spring-aop/scripts/env.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
export PATH="$JAVA_HOME/bin:$PATH"
MVN="${MVN:-mvn}"
JAR="target/spring-aop-demo-1.0.0.jar"
APP_MAIN="com.ankurm.aop.AopApplication"
APP_PORT="${APP_PORT:-8080}"
# Strip environment noise that is an artefact of the machine, not of Spring:
# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy
# truststore is configured, and it would otherwise end up in every committed transcript.
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
# Start the demo jar detached, record its PID, and block until it answers.
# Extra arguments are passed to the application. Environment variables for the run are
# passed by setting them on the call: `APP_ENV="A=1 B=2" start_app --spring.profiles.active=x`
start_app() {
stop_app
mkdir -p target
# Deliberately NOT setsid: setsid forks when it is not already a process-group leader,
# so $! would be the PID of a process that exits immediately and the JVM would survive
# every later stop_app. A surviving JVM keeps the port, the next scenario fails to bind,
# and curl answers from the previous scenario -- which reads exactly like the
# configuration change under test having had no effect. Three wrong findings in this
# repository came from that before it was tracked down.
if [ -n "${APP_ENV:-}" ]; then
# shellcheck disable=SC2086
env $APP_ENV nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null &
else
nohup java -jar "$JAR" "$@" > /tmp/aop-demo.log 2>&1 < /dev/null &
fi
echo $! > target/app.pid
for _ in $(seq 1 60); do
curl -s -o /dev/null "http://127.0.0.1:${APP_PORT}/aop/proxies" 2>/dev/null && return 0
kill -0 "$(cat target/app.pid)" 2>/dev/null || { echo "JVM exited during startup:"
tail -20 /tmp/aop-demo.log; return 1; }
sleep 1
done
echo "application did not answer"; tail -20 /tmp/aop-demo.log; return 1
}
# Stop it by recorded PID. Never by pattern: `ps | grep <jar name>` also matches the shell
# running the script, because the jar name is on that shell's own command line.
stop_app() {
if [ -f target/app.pid ]; then
pid=$(cat target/app.pid)
if [ -n "$pid" ] && grep -qa "spring-aop-demo" "/proc/$pid/cmdline" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true # reap, so bash prints no "Killed" notice
fi
rm -f target/app.pid
fi
for _ in $(seq 1 40); do
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
sleep 0.25
done
exec 3<&- 2>/dev/null || true
}
# Print the precedence report for one property, compactly.

16
spring-aop/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Regenerate every transcript under docs/output/.
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
"$MVN" -B -q package -DskipTests
for demo in versions designators proxy-types broken-gallery; do
echo "=== $demo ==="
"scripts/demo-$demo.sh" > /dev/null
done
stop_app
echo
echo "regenerated:"
ls -1 docs/output/

View File

@@ -0,0 +1,20 @@
package com.ankurm.aop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for the ankurm.com article
* "Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn't Firing".
*
* <p>Two halves. {@code aspect/} and {@code service/} contain aspects that work, one per
* pointcut designator, so the designator reference in the article is generated from real
* matches. {@code broken/} contains aspects that do not fire, each for a different reason,
* each paired with the fix.
*/
@SpringBootApplication
public class AopApplication {
public static void main(String[] args) {
SpringApplication.run(AopApplication.class, args);
}
}

View File

@@ -0,0 +1,141 @@
package com.ankurm.aop.aspect;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import com.ankurm.aop.service.Audited;
import com.ankurm.aop.service.Marker;
import com.ankurm.aop.service.OrderService;
import com.ankurm.aop.service.Payload;
import org.springframework.stereotype.Component;
/**
* One advice per pointcut designator Spring AOP supports. Ten designators, ten pieces of
* advice, all on the same small set of beans, so the article's reference table can be
* generated from {@code /aop/designators} rather than transcribed.
*
* <p>The designators fall into three groups worth keeping straight:
* <ul>
* <li><strong>Signature matching</strong> &mdash; {@code execution}, {@code within}.
* Evaluated statically against the method signature.</li>
* <li><strong>Runtime type matching</strong> &mdash; {@code this}, {@code target},
* {@code args}, {@code @target}, {@code @args}. These force a runtime check on every
* candidate call, which is why they cost more than they look like they should.</li>
* <li><strong>Annotation and bean matching</strong> &mdash; {@code @within},
* {@code @annotation}, {@code bean}. The last is Spring's own, not AspectJ's.</li>
* </ul>
*/
@Aspect
@Component
public class DesignatorAspect {
private final MatchRecorder recorder;
public DesignatorAspect(MatchRecorder recorder) {
this.recorder = recorder;
}
/**
* A named pointcut, so the same expression is not repeated in six places. Naming
* pointcuts is the single biggest readability win available in Spring AOP.
*/
@Pointcut("within(com.ankurm.aop.service..*)")
public void inServiceLayer() {
}
// -- signature matching ------------------------------------------------------------
/** Matches method executions by signature. The workhorse; everything else is a filter. */
@Before("execution(public String com.ankurm.aop.service.OrderService.place(String, int))")
public void executionByFullSignature(JoinPoint jp) {
record("execution (full signature)", jp);
}
/** The same designator with wildcards, which is how it is normally written. */
@Before("execution(* com.ankurm.aop.service.*Service.cancel(..))")
public void executionWithWildcards(JoinPoint jp) {
record("execution (wildcards)", jp);
}
/** Limits matching to join points inside a type. Static: no runtime test. */
@Before("within(com.ankurm.aop.service.InventoryService)")
public void withinType(JoinPoint jp) {
record("within", jp);
}
// -- runtime type matching ---------------------------------------------------------
/**
* {@code this} tests the PROXY. With a JDK proxy the proxy implements the interface but
* is not an instance of the implementation class, so {@code this(DefaultOrderService)}
* does not match. With a CGLIB proxy it does, because the proxy is a subclass.
* That difference is measured in {@code docs/output/03-proxy-types.txt}.
*/
@Before("this(com.ankurm.aop.service.OrderService) && inServiceLayer()")
public void thisProxyIsOrderService(JoinPoint jp) {
record("this(OrderService)", jp);
}
/** {@code target} tests the object BEHIND the proxy, so the proxy type is irrelevant. */
@Before("target(com.ankurm.aop.service.DefaultOrderService)")
public void targetIsImplementation(JoinPoint jp) {
record("target(DefaultOrderService)", jp);
}
/** Matches on the runtime types of the arguments, and can bind them. */
@Before("args(sku, quantity) && inServiceLayer()")
public void argsByType(JoinPoint jp, String sku, int quantity) {
record("args (bound: " + sku + "/" + quantity + ")", jp);
}
/** The class of the executing object carries the annotation. Runtime test. */
@Before("@target(com.ankurm.aop.service.Audited) && execution(* *.cancel(..))")
public void targetClassAnnotated(JoinPoint jp) {
record("@target(Audited)", jp);
}
/** The runtime types of the arguments carry the annotation. */
@Before("@args(com.ankurm.aop.service.Trackable) && inServiceLayer()")
public void argumentTypesAnnotated(JoinPoint jp) {
record("@args(Trackable)", jp);
}
// -- annotation and bean matching --------------------------------------------------
/** Declaring type carries the annotation. Static, so cheaper than {@code @target}. */
@Before("@within(com.ankurm.aop.service.Audited) && execution(* *.place(..))")
public void declaringTypeAnnotated(JoinPoint jp) {
record("@within(Audited)", jp);
}
/** The method itself carries the annotation. The one most people reach for first. */
@Before("@annotation(com.ankurm.aop.service.Marker)")
public void methodAnnotated(JoinPoint jp) {
record("@annotation(Marker)", jp);
}
/** Spring's own designator: match by bean name, wildcards allowed. Not AspectJ. */
@Before("bean(inventoryService)")
public void byBeanName(JoinPoint jp) {
record("bean(inventoryService)", jp);
}
/** Wildcards work on bean names too, which is the usual reason to use this designator. */
@Before("bean(*OrderService)")
public void byBeanNameWildcard(JoinPoint jp) {
record("bean(*OrderService)", jp);
}
private void record(String designator, JoinPoint jp) {
recorder.record(designator, jp.getSignature().toShortString());
}
/** Referenced by {@code @args} so the argument type is used; keeps the compiler honest. */
@SuppressWarnings("unused")
private static Payload unused(OrderService service, Marker marker, Audited audited) {
return null;
}
}

View File

@@ -0,0 +1,30 @@
package com.ankurm.aop.aspect;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* Collects which designator matched which join point, so the designator table in the article
* is a record of what actually fired rather than a restatement of the reference documentation.
*/
@Component
public class MatchRecorder {
private final Map<String, Set<String>> matches = new LinkedHashMap<>();
public void record(String designator, String joinPoint) {
matches.computeIfAbsent(designator, key -> new LinkedHashSet<>()).add(joinPoint);
}
public Map<String, Set<String>> matches() {
return matches;
}
public void clear() {
matches.clear();
}
}

View File

@@ -0,0 +1,118 @@
package com.ankurm.aop.broken;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import com.ankurm.aop.aspect.MatchRecorder;
import org.springframework.stereotype.Component;
/**
* The gallery. Each aspect here is written the way it is commonly written and does not fire,
* for a different reason. {@code /aop/broken} exercises them all and reports which ones
* recorded a match, so the article's table of failure modes is generated from real misses.
*
* <p>The working versions live in {@link com.ankurm.aop.aspect.DesignatorAspect}; each entry
* below names the fix.
*/
public final class BrokenAspects {
private BrokenAspects() {
}
/**
* <strong>1. The aspect is not a Spring bean.</strong>
*
* <p>{@code @Aspect} is an AspectJ annotation. It tells Spring how to interpret a bean it
* already has; it does not make the class into a bean. Without {@code @Component} (or an
* {@code @Bean} method, or a component-scan stereotype) the class is never instantiated
* and the pointcut is never registered.
*
* <p>This is the single most common cause, and the most invisible: there is no warning,
* because from Spring's point of view nothing was ever asked for.
*
* <p><strong>Fix:</strong> add {@code @Component}.
*/
@Aspect
public static class NotABean {
@Before("execution(* com.ankurm.aop.service.*.*(..))")
public void neverRuns() {
Recorder.record("1. @Aspect without @Component");
}
}
/**
* <strong>2. The pointcut expression does not match anything.</strong>
*
* <p>A pointcut that matches nothing is indistinguishable at runtime from an aspect that
* was never registered. Here the package is {@code com.ankurm.aop.services} &mdash; note
* the plural &mdash; which does not exist. AspectJ parses it happily: a package name that
* matches no type is not an error, it is an empty match set.
*
* <p><strong>Fix:</strong> check the expression against
* {@code AspectJExpressionPointcut#matches} in a test, or start from
* {@code within(com.example..*)} and narrow.
*/
@Aspect
@Component
public static class PackageTypo {
@Before("execution(* com.ankurm.aop.services.*.*(..))")
public void neverRuns() {
Recorder.record("2. pointcut matches nothing (package typo)");
}
}
/**
* <strong>3. Advising a private method.</strong>
*
* <p>Both proxy strategies work by dispatching through something that wraps the target: a
* JDK proxy implements the interface, a CGLIB proxy extends the class. Neither can
* intercept a private method, because neither can override one. The pointcut is legal and
* simply never matches.
*
* <p><strong>Fix:</strong> make the method at least package-visible and call it from
* outside the object, or move it to a collaborator.
*/
@Aspect
@Component
public static class PrivateMethod {
@Before("execution(private * com.ankurm.aop.service.InventoryService.hidden(..))")
public void neverRuns() {
Recorder.record("3. advice on a private method");
}
}
/**
* <strong>4. Advising a final method.</strong>
*
* <p>Spring Boot proxies with CGLIB by default, which subclasses the target. A final
* method cannot be overridden, so the subclass inherits the original and calls go
* straight to it. No error is raised for a final method &mdash; only a final <em>class</em>
* fails loudly, because then the subclass itself is impossible.
*
* <p><strong>Fix:</strong> remove {@code final}, or proxy by interface instead.
*/
@Aspect
@Component
public static class FinalMethod {
@Before("execution(* com.ankurm.aop.service.InventoryService.finalCheck(..))")
public void neverRuns() {
Recorder.record("4. advice on a final method");
}
}
/** Static hand-off so the broken aspects can report without each taking a constructor. */
static final class Recorder {
private static MatchRecorder delegate;
static void bind(MatchRecorder recorder) {
delegate = recorder;
}
static void record(String what) {
if (delegate != null) {
delegate.record("FIRED " + what, "unexpected");
}
}
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
/**
* <strong>6. The object was never a bean.</strong>
*
* <p>Spring AOP advises beans. An instance created with {@code new} &mdash; in a helper, in a
* factory, in a test &mdash; has no proxy around it and never will, no matter how many
* annotations it carries. The annotation is a request to the container, and the container
* was not involved.
*
* <p><strong>Fix:</strong> get the instance from the container. If it genuinely must be
* constructed by hand and still advised, that is what AspectJ load-time weaving is for.
*/
public class NewedUpService {
@Marker
public String work() {
return "worked";
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.aop.broken;
import com.ankurm.aop.service.Marker;
import org.springframework.stereotype.Service;
/**
* <strong>5. Self-invocation.</strong>
*
* <p>The most expensive failure mode in the gallery, because the code looks correct and the
* annotation is right there. {@link #outer()} is called through the proxy, so advice on it
* runs. The call it then makes to {@link #inner()} is a plain {@code this.inner()} on the
* target object &mdash; the proxy is not involved, so advice on {@code inner()} never runs.
*
* <p>This is the same mechanism that makes {@code @Transactional} and {@code @Cacheable}
* silently do nothing on internal calls, which is why it is worth understanding once rather
* than three times.
*
* <p><strong>Fix:</strong> move {@code inner()} to another bean. Failing that, inject the
* bean into itself and call through that reference, or use
* {@code AopContext.currentProxy()} with {@code exposeProxy = true} &mdash; both work and
* both are worse.
*/
@Service
public class SelfInvokingService {
@Marker
public String outer() {
return "outer -> " + inner();
}
/** Advised in principle. Never advised in practice when reached from {@link #outer()}. */
@Marker
public String inner() {
return "inner";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Type-level annotation, matched by {@code @within(..)} and {@code @target(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Audited {
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* Implements an interface, so by default Spring AOP proxies it with a JDK dynamic proxy
* &mdash; except that Spring Boot flips the global default to class-based proxies. Which
* one you actually get is measured, not assumed: see
* {@code docs/output/03-proxy-types.txt}.
*/
@Service
@Audited
public class DefaultOrderService implements OrderService {
@Override
@Marker
public String place(String sku, int quantity) {
return "placed " + quantity + " x " + sku;
}
@Override
public String cancel(String id) {
return "cancelled " + id;
}
@Override
public String slow() {
try {
Thread.sleep(25);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
return "slept";
}
/** Not on the interface. A JDK proxy cannot intercept this; a CGLIB proxy can. */
public String interfaceless(Payload payload) {
return "handled " + payload.value();
}
}

View File

@@ -0,0 +1,26 @@
package com.ankurm.aop.service;
import org.springframework.stereotype.Service;
/**
* No interface, so this bean can only be proxied by CGLIB, and only its non-final,
* non-private methods can be advised.
*/
@Service
public class InventoryService {
public String reserve(String sku) {
return "reserved " + sku;
}
/** CGLIB proxies by subclassing. A final method cannot be overridden, so it cannot be advised. */
public final String finalCheck(String sku) {
return "checked " + sku;
}
/** Neither can a private one. Included so the article can show both misses in one class. */
@SuppressWarnings("unused")
private String hidden() {
return "hidden";
}
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Method-level annotation, matched by {@code @annotation(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Marker {
}

View File

@@ -0,0 +1,8 @@
package com.ankurm.aop.service;
/** Interface, so this bean can be proxied by a JDK dynamic proxy. */
public interface OrderService {
String place(String sku, int quantity);
String cancel(String id);
String slow();
}

View File

@@ -0,0 +1,6 @@
package com.ankurm.aop.service;
/** Argument type carrying a type annotation, so {@code @args(..)} has something to match. */
@Trackable
public record Payload(String value) {
}

View File

@@ -0,0 +1,12 @@
package com.ankurm.aop.service;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Parameter-type annotation, matched by {@code @args(..)}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Trackable {
}

View File

@@ -0,0 +1,98 @@
package com.ankurm.aop.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.service.DefaultOrderService;
import com.ankurm.aop.service.InventoryService;
import com.ankurm.aop.service.OrderService;
import com.ankurm.aop.service.Payload;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints what the container actually built, which is the fastest way to answer "why isn't my
* aspect firing".
*
* <p>Almost every non-firing aspect is one of three things, and all three are visible here:
* the bean is not proxied at all, the bean is proxied by the wrong kind of proxy, or the
* advisor list attached to the proxy does not contain your advice. Guessing between them
* wastes an afternoon; reading them takes a second.
*
* <p>Documented in {@code docs/06-diagnosing-a-silent-aspect.md}. Delete before shipping.
*/
@RestController
public class AopDiagnosticsEndpoint {
private final ApplicationContext context;
private final OrderService orderService;
private final InventoryService inventoryService;
private final MatchRecorder recorder;
public AopDiagnosticsEndpoint(ApplicationContext context, OrderService orderService,
InventoryService inventoryService, MatchRecorder recorder) {
this.context = context;
this.orderService = orderService;
this.inventoryService = inventoryService;
this.recorder = recorder;
}
/** For each interesting bean: is it a proxy, which kind, and what advice is attached. */
@GetMapping("/aop/proxies")
public List<Map<String, Object>> proxies() {
List<Map<String, Object>> rows = new ArrayList<>();
for (String name : List.of("defaultOrderService", "inventoryService",
"selfInvokingService", "newedUpService")) {
if (!context.containsBean(name)) {
continue;
}
Object bean = context.getBean(name);
Map<String, Object> row = new LinkedHashMap<>();
row.put("bean", name);
row.put("class", bean.getClass().getName());
row.put("isProxy", AopUtils.isAopProxy(bean));
row.put("proxyKind", AopUtils.isJdkDynamicProxy(bean) ? "JDK dynamic proxy"
: AopUtils.isCglibProxy(bean) ? "CGLIB subclass" : "not proxied");
row.put("targetClass", AopUtils.getTargetClass(bean).getName());
if (bean instanceof Advised advised) {
row.put("advisorCount", advised.getAdvisors().length);
row.put("advisors", java.util.Arrays.stream(advised.getAdvisors())
.map(a -> a.getAdvice().getClass().getSimpleName()).toList());
row.put("proxiedInterfaces", java.util.Arrays.stream(advised.getProxiedInterfaces())
.map(Class::getSimpleName).toList());
}
rows.add(row);
}
return rows;
}
/** Exercise every advised method, then report which designator matched what. */
@GetMapping("/aop/designators")
public Map<String, Object> designators() {
recorder.clear();
orderService.place("SKU-1", 2);
orderService.cancel("ORD-9");
inventoryService.reserve("SKU-1");
inventoryService.finalCheck("SKU-1");
if (orderService instanceof DefaultOrderService concrete) {
concrete.interfaceless(new Payload("p"));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("orderServiceProxyKind",
AopUtils.isJdkDynamicProxy(orderService) ? "JDK dynamic proxy"
: AopUtils.isCglibProxy(orderService) ? "CGLIB subclass" : "not proxied");
result.put("orderServiceIsDefaultOrderServiceInstance",
orderService instanceof DefaultOrderService);
result.put("matches", recorder.matches());
return result;
}
}

View File

@@ -0,0 +1,90 @@
package com.ankurm.aop.web;
import java.util.LinkedHashMap;
import java.util.Map;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.broken.NewedUpService;
import com.ankurm.aop.broken.SelfInvokingService;
import com.ankurm.aop.service.InventoryService;
import org.springframework.aop.support.AopUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Exercises every entry in the broken-aspect gallery and reports whether its advice fired.
*
* <p>Every row should read {@code advice fired: false}. A row that reads {@code true} means
* the failure mode it documents no longer exists in this Spring version, which would be worth
* knowing about.
*/
@RestController
public class BrokenGalleryEndpoint {
private final SelfInvokingService selfInvoking;
private final InventoryService inventory;
private final MatchRecorder recorder;
public BrokenGalleryEndpoint(SelfInvokingService selfInvoking, InventoryService inventory,
MatchRecorder recorder) {
this.selfInvoking = selfInvoking;
this.inventory = inventory;
this.recorder = recorder;
}
@GetMapping("/aop/broken")
public Map<String, Object> broken() {
Map<String, Object> result = new LinkedHashMap<>();
// 5. Self-invocation. outer() is advised; the inner() it calls is not.
recorder.clear();
String viaOuter = selfInvoking.outer();
boolean innerAdvisedViaOuter = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
// The same method called directly through the proxy IS advised -- proof that the
// method is advisable and only the call path was the problem.
recorder.clear();
selfInvoking.inner();
boolean innerAdvisedDirectly = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
Map<String, Object> selfInvocation = new LinkedHashMap<>();
selfInvocation.put("beanIsProxied", AopUtils.isAopProxy(selfInvoking));
selfInvocation.put("result", viaOuter);
selfInvocation.put("innerAdvisedWhenCalledFromOuter", innerAdvisedViaOuter);
selfInvocation.put("innerAdvisedWhenCalledDirectly", innerAdvisedDirectly);
result.put("5-self-invocation", selfInvocation);
// 6. An instance created with new is not a bean and is not proxied.
NewedUpService newed = new NewedUpService();
recorder.clear();
newed.work();
Map<String, Object> newedUp = new LinkedHashMap<>();
newedUp.put("isProxy", AopUtils.isAopProxy(newed));
newedUp.put("adviceFired", !recorder.matches().isEmpty());
result.put("6-created-with-new", newedUp);
// 3 and 4. Private and final methods on a proxied bean.
recorder.clear();
inventory.finalCheck("SKU-1");
Map<String, Object> finalMethod = new LinkedHashMap<>();
finalMethod.put("beanIsProxied", AopUtils.isAopProxy(inventory));
finalMethod.put("proxyKind", AopUtils.isCglibProxy(inventory) ? "CGLIB subclass" : "other");
finalMethod.put("adviceFired", !recorder.matches().isEmpty());
result.put("4-final-method", finalMethod);
// 1 and 2 cannot fire by construction; report that nothing recorded a FIRED marker.
result.put("1-aspect-without-component-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 1")));
result.put("2-pointcut-typo-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 2")));
result.put("3-private-method-fired", recorder.matches().keySet().stream()
.anyMatch(k -> k.startsWith("FIRED 3")));
result.put("note", "every value above should be false except the two that prove the "
+ "method is advisable when reached through the proxy");
return result;
}
}

View File

@@ -0,0 +1,83 @@
package com.ankurm.aop.web;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.ankurm.aop.service.DefaultOrderService;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Asks the pointcut parser directly which designators it accepts, and what it says when it
* refuses one.
*
* <p>The list of unsupported designators is documented, but the error you get is not, and the
* error is what you will actually be looking at. Being able to recognise it saves the ten
* minutes it otherwise takes to work out that {@code call()} is an AspectJ designator Spring
* AOP has never supported.
*/
@RestController
public class PointcutParserEndpoint {
/** Designators the reference documentation lists as unsupported in Spring AOP. */
private static final List<String> UNSUPPORTED = List.of(
"call(* com.ankurm.aop.service.OrderService.place(..))",
"get(* com.ankurm.aop.service.*.*)",
"set(* com.ankurm.aop.service.*.*)",
"initialization(com.ankurm.aop.service.*.new(..))",
"staticinitialization(com.ankurm.aop.service.*)",
"preinitialization(com.ankurm.aop.service.*.new(..))",
"handler(java.lang.Exception)",
"adviceexecution()",
"withincode(* com.ankurm.aop.service.*.*(..))",
"cflow(execution(* com.ankurm.aop.service.*.*(..)))",
"cflowbelow(execution(* com.ankurm.aop.service.*.*(..)))",
"if()",
"@this(com.ankurm.aop.service.Audited)",
"@withincode(com.ankurm.aop.service.Marker)");
private static final List<String> SUPPORTED = List.of(
"execution(* com.ankurm.aop.service.OrderService.place(..))",
"within(com.ankurm.aop.service..*)",
"this(com.ankurm.aop.service.OrderService)",
"target(com.ankurm.aop.service.DefaultOrderService)",
"args(String, int)",
"@target(com.ankurm.aop.service.Audited)",
"@args(com.ankurm.aop.service.Trackable)",
"@within(com.ankurm.aop.service.Audited)",
"@annotation(com.ankurm.aop.service.Marker)",
"bean(defaultOrderService)");
@GetMapping("/aop/parser")
public Map<String, Object> parser() throws Exception {
Map<String, Object> result = new LinkedHashMap<>();
result.put("supported", SUPPORTED.stream().map(this::probe).toList());
result.put("unsupported", UNSUPPORTED.stream().map(this::probe).toList());
return result;
}
/** Parse and evaluate one expression, reporting what the parser did with it. */
private Map<String, Object> probe(String expression) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("expression", expression);
try {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(expression);
// Force evaluation: setExpression only stores the string, so an expression is not
// rejected until something asks it to match.
pointcut.matches(DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class);
row.put("accepted", true);
} catch (Exception ex) {
row.put("accepted", false);
row.put("exception", ex.getClass().getName());
String message = ex.getMessage();
row.put("message", message == null ? null
: message.length() > 220 ? message.substring(0, 220) + "..." : message);
}
return row;
}
}

View File

@@ -0,0 +1,10 @@
spring:
application:
name: spring-aop-demo
server:
port: 8080
logging:
level:
root: WARN

View File

@@ -0,0 +1,104 @@
package com.ankurm.aop;
import com.ankurm.aop.aspect.MatchRecorder;
import com.ankurm.aop.broken.NewedUpService;
import com.ankurm.aop.broken.SelfInvokingService;
import com.ankurm.aop.service.DefaultOrderService;
import com.ankurm.aop.service.InventoryService;
import com.ankurm.aop.service.OrderService;
import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/** Pins the claims the AOP article makes about proxies, designators and failure modes. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AopContractTests {
@Autowired OrderService orderService;
@Autowired InventoryService inventoryService;
@Autowired SelfInvokingService selfInvoking;
@Autowired MatchRecorder recorder;
@Test
@DisplayName("Spring Boot proxies with CGLIB even when the bean implements an interface")
void bootDefaultsToCglib() {
assertThat(AopUtils.isCglibProxy(orderService)).isTrue();
assertThat(orderService).isInstanceOf(DefaultOrderService.class);
}
@Test
@DisplayName("self-invocation: the inner call is not advised, the direct call is")
void selfInvocationSkipsAdvice() {
recorder.clear();
selfInvoking.outer();
boolean viaOuter = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
recorder.clear();
selfInvoking.inner();
boolean direct = recorder.matches().values().stream()
.flatMap(java.util.Set::stream).anyMatch(s -> s.contains("inner"));
assertThat(viaOuter).as("reached through this.inner() -- proxy not involved").isFalse();
assertThat(direct).as("reached through the proxy -- advice runs").isTrue();
}
@Test
@DisplayName("an object created with new is never advised")
void newedUpObjectIsNotAdvised() {
NewedUpService service = new NewedUpService();
recorder.clear();
service.work();
assertThat(AopUtils.isAopProxy(service)).isFalse();
assertThat(recorder.matches()).isEmpty();
}
@Test
@DisplayName("a final method on a proxied bean is not advised, and no error is raised")
void finalMethodIsNotAdvised() {
assertThat(AopUtils.isAopProxy(inventoryService)).isTrue();
recorder.clear();
inventoryService.finalCheck("SKU-1");
assertThat(recorder.matches()).isEmpty();
}
/**
* The reference documentation states that an unsupported designator produces an
* {@code IllegalArgumentException}. It does not. Catching that type will not catch this.
*/
@Test
@DisplayName("unsupported designators throw UnsupportedPointcutPrimitiveException, not IAE")
void unsupportedDesignatorExceptionType() throws Exception {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("call(* com.ankurm.aop.service.OrderService.place(..))");
assertThatExceptionOfType(UnsupportedPointcutPrimitiveException.class)
.isThrownBy(() -> pointcut.matches(
DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class))
.withMessageContaining("unsupported pointcut primitive 'call'");
assertThat(UnsupportedPointcutPrimitiveException.class)
.as("it extends RuntimeException directly, not IllegalArgumentException")
.hasSuperclass(RuntimeException.class);
}
@Test
@DisplayName("a pointcut naming a package that does not exist parses and matches nothing")
void pointcutTypoIsSilent() throws Exception {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.ankurm.aop.services.*.*(..))"); // plural
assertThat(pointcut.matches(
DefaultOrderService.class.getMethod("place", String.class, int.class),
DefaultOrderService.class)).isFalse();
}
}