Some behaviour does not belong to any one method but is wanted in forty of them: timing, audit logging, security checks, transaction boundaries. Copying it into each is how the business logic disappears under plumbing, and how the forty-first method ends up without it. AOP moves that behaviour into one place.
This lesson also explains the proxy rule, which is the single most useful thing in
this whole track — because @Transactional, @Cacheable,
@Async, @Retryable and @PreAuthorize are all built on the same
mechanism and all fail in the same silent way.
Setup
<!-- NOTE THE ARTIFACT NAME. Spring Boot 3 called this spring-boot-starter-aop.
Boot 4 renamed it to spring-boot-starter-aspectj, and the old name is not
in the BOM at all - so a Boot 3 pom fails with "version is missing" rather
than with anything that mentions the rename. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>No @EnableAspectJAutoProxy is needed — Boot auto-configures it once the starter is
present.
The vocabulary, in one example
Here is the pizza API's timing aspect in full. Every AOP term is visible in it:
@Slf4j
@Aspect
@Component
public class ServiceTimingAspect {
private static final long SLOW_CALL_MILLIS = 250;
@Pointcut("execution(* com.pizza.api.entity..*ServiceImpl.*(..))")
public void serviceLayer() {}
@Pointcut("execution(* com.pizza.api.report.*ServiceImpl.*(..))")
public void reportLayer() {}
@Around("serviceLayer() || reportLayer()")
public Object time(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
String target = joinPoint.getSignature().toShortString();
try {
return joinPoint.proceed();
} catch (Throwable ex) {
long millis = (System.nanoTime() - start) / 1_000_000;
log.warn("{} failed after {} ms: {}", target, millis, ex.toString());
throw ex;
} finally {
long millis = (System.nanoTime() - start) / 1_000_000;
if (millis >= SLOW_CALL_MILLIS) {
log.warn("SLOW {} took {} ms", target, millis);
} else if (log.isDebugEnabled()) {
log.debug("{} took {} ms", target, millis);
}
}
}
}| Term | What it is here |
|---|---|
| Aspect | the class — a module of cross-cutting behaviour |
| Join point | a method call that could be intercepted |
| Pointcut | the expression selecting which ones |
| Advice | time(…) — the code that runs |
| Weaving | Spring wrapping the bean in a proxy at startup |
Reading a pointcut
execution(* com.pizza.api.entity..*ServiceImpl.*(..))
// ▲ ▲ ▲ ▲ ▲
// │ │ │ │ └── any arguments
// │ │ │ └──── any method name
// │ │ └─────────────── class name ends "ServiceImpl"
// │ └────────────────────────────────────── package and sub-packages (..)
// └──────────────────────────────────────── any return typeNote the difference between . and ..: one dot is a literal separator,
two dots means "this package and everything below it". A pointcut that silently matches nothing is
almost always a . that should have been ...
Other useful forms:
// Every method in classes annotated with @RestController
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
public void controllers() {}
// Every method annotated with a specific annotation
@Pointcut("@annotation(com.pizza.api.aspect.Audited)")
public void audited() {}
// Combine them
@Around("controllers() && !audited()")Named pointcuts are worth the extra lines. The expression is declared once, and the advice then reads as prose instead of as a regex.
The five advice types
| Advice | Runs | Can it stop the call? |
|---|---|---|
@Before | before | only by throwing |
@AfterReturning | after success | no |
@AfterThrowing | after an exception | no |
@After | always, like finally | no |
@Around | wraps the call | yes |
@Around is the only one that can see the outcome and decide whether the call
happens at all. It must call joinPoint.proceed() and return its result — forget, and the
method never runs and every caller silently gets null.
Note what the aspect above deliberately does not do: it never swallows the
exception. It logs and rethrows. An aspect that quietly turns a failure into a null is
how observability code becomes the bug.
⚠️ The proxy rule
Spring implements all of this by wrapping your bean in a proxy. The container hands the proxy to everyone who injects the bean, so calls arriving from another bean pass through the advice.
another bean ──▶ [ proxy ] ──▶ [ your bean ]
▲ │
advice runs │ this.b() goes straight here.
└─▶ no proxy, no advice.Which means:
@Service
public class ProductServiceImpl {
public void a() {
b(); // NOT advised - internal call, the proxy is not involved
}
@Transactional // no transaction when called from a()
public void b() { /* … */ }
}This is not an AOP quirk. It applies identically to:
@Transactional— no transaction, and the write commits outside one@Cacheable— nothing cached, the method just runs every time@Async— runs on the caller's thread, synchronously@Retryable— no retry, the first failure propagates@PreAuthorize— no authorization check at all
Every one of these fails silently. Nothing logs, nothing throws; the behaviour is simply absent. When an annotation "isn't working", a self-invocation is the first thing to check.
The fixes, best first:
- Move the annotated method to another bean. Usually the right answer — if
b()deserves its own transaction, it is doing its own job. - Inject the interface into itself and call through it, so the call goes via the proxy. Works, and looks strange enough that it needs a comment.
AopContext.currentProxy()— requiresexposeProxy = trueand is a last resort.
Two related consequences of proxying: private and final methods
are never advised (a proxy cannot override them), and by default Spring proxies the interface
if there is one, so the bean type you inject should be the interface.
When AOP is the wrong tool
AOP hides behaviour. That is the point, and it is also the cost: someone reading
createOrder cannot see that it is being timed. Worth it for genuinely uniform,
genuinely cross-cutting concerns — logging, metrics, transactions, security. Not worth it for
business rules, where "why did this happen?" should be answerable by reading the method.
Prefer the framework's own annotations where they exist. Do not write a caching aspect; use
@Cacheable.
What to take from this
- Boot 4:
spring-boot-starter-aspectj, not-aop. @Aroundmust callproceed()and should rethrow, never swallow.- The proxy rule: a self-invocation is never advised, and this silently breaks
@Transactional,@Cacheable,@Async,@Retryableand@PreAuthorizetoo. - Use it for cross-cutting concerns, not for business logic.
Next: application events — the other way to decouple, and the transaction-phase mistake that emails customers about orders that were rolled back.