Spring AOP & Aspect-Oriented Programming
Spring AOP from pointcut syntax to proxy mechanics: the five advice types, writing annotation-driven aspects, aspect ordering, and why self-invocation silently does nothing.
On this page
Cross-cutting concerns — timing, auditing, retries, transactions — spread through a codebase as copy-pasted try/finally blocks. AOP moves that code into one place and applies it declaratively. Spring's implementation is deliberately narrow: proxy-based, method-execution only, and Spring beans only. Knowing exactly where those limits are is what separates an aspect that works from one that silently does nothing.
Key Takeaways
- Spring AOP advises public method calls on Spring beans, through a proxy. Nothing else.
@Aroundis the only advice that can change arguments, replace the return value, or swallow an exception.- Self-invocation bypasses the proxy — the single most common AOP bug in the framework.
- Prefer
@annotation(...)pointcuts over package wildcards: they are explicit at the call site and survive refactoring. - Order aspects with
@Order; a lower number runs further out in the chain.
Vocabulary, briefly
| Term | Meaning |
|---|---|
| Join point | A point where advice can apply. In Spring AOP, always a method execution. |
| Pointcut | An expression selecting join points. |
| Advice | The code to run at a matched join point. |
| Aspect | A class combining pointcuts and advice. |
| Weaving | Linking aspects to targets. Spring does it at runtime with proxies. |
How the proxy works
The dotted line is the whole self-invocation problem in one arrow. When place() calls
this.validate(), the call goes straight to the target instance. There is no proxy in the path, so
@Transactional, @Cacheable, @Retry or your custom aspect on validate() do nothing at all —
and nothing warns you.
Three ways out, in order of preference:
- Move the method to a different bean. Usually the cycle was telling you the class does two things.
- Inject a self-reference and call through it.
AopContext.currentProxy(), which requiresexposeProxy = trueand couples your code to Spring.
@Service
public class OrderService {
private final OrderService self;
// @Lazy breaks the constructor cycle the self-reference would otherwise create.
public OrderService(@Lazy OrderService self) {
this.self = self;
}
public void place(Order order) {
self.validate(order); // goes through the proxy — advice applies
}
@Retryable(maxAttempts = 3)
public void validate(Order order) { }
}The five advice types
@Aspect
@Component
public class AuditAspect {
private static final Logger log = LoggerFactory.getLogger(AuditAspect.class);
// A named pointcut can be reused and is far easier to read than
// repeating the expression on every advice method.
@Pointcut("@annotation(com.acme.audit.Audited)")
void audited() { }
@Before("audited()")
public void before(JoinPoint jp) {
log.debug("entering {}", jp.getSignature().toShortString());
}
@AfterReturning(pointcut = "audited()", returning = "result")
public void onSuccess(JoinPoint jp, Object result) {
log.debug("{} returned {}", jp.getSignature().getName(), result);
}
@AfterThrowing(pointcut = "audited()", throwing = "ex")
public void onFailure(JoinPoint jp, Throwable ex) {
log.warn("{} threw {}", jp.getSignature().getName(), ex.toString());
}
@After("audited()")
public void always(JoinPoint jp) {
MDC.remove("auditId"); // runs on both paths, like a finally block
}
@Around("audited()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // omit this and the method never runs
} finally {
long micros = (System.nanoTime() - start) / 1_000;
log.info("{} took {}us", pjp.getSignature().getName(), micros);
}
}
}@Around is the powerful one and the one to use sparingly. It can modify arguments with
pjp.proceed(newArgs), substitute a return value, or catch and translate exceptions. It is also the
only advice that can accidentally break the application by forgetting to call proceed().
Pointcut expressions
// Any public method on any class ending in Service, in any sub-package
execution(public * com.acme..*Service.*(..))
// Methods annotated with a custom annotation — the most maintainable form
@annotation(com.acme.audit.Audited)
// Any method on a class annotated at type level
@within(org.springframework.stereotype.Repository)
// Bind an argument so advice can inspect it
execution(* com.acme..*.charge(..)) && args(order, ..)
// Restrict to one bean by name pattern
bean(*Repository)
// Combine
@annotation(com.acme.audit.Audited) && !execution(* *.toString())Package-wildcard pointcuts look elegant and age badly: someone renames a package and the aspect silently stops matching. Annotation-driven pointcuts state the intent at the point of use, survive refactoring, and let a reader of the service class see that something is applied.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String value() default "";
}To read the annotation's own attributes, bind it as a parameter:
@Around("@annotation(audited)")
public Object around(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
String category = audited.value();
return pjp.proceed();
}Ordering multiple aspects
When several aspects match the same method they nest. @Order controls the nesting: the lowest
value is outermost.
Ordering is not cosmetic. Retry outside the transaction retries the whole unit of work; retry inside it reuses a transaction that may already be marked rollback-only. Metrics outside retry measures total wall-clock time including retries; inside, it measures one attempt. Decide which you want, then set the order explicitly rather than relying on the arbitrary default.
Proxy mechanics and their limits
Spring Boot uses CGLIB by default (spring.aop.proxy-target-class=true). CGLIB generates a subclass
at runtime and overrides methods to insert the interceptor chain. That imposes real constraints:
- The class cannot be
final, and neither can the advised methods. privateandstaticmethods are never advised — they cannot be overridden.- Field access is never intercepted, only method calls.
- The proxy runs the subclass constructor, so initialisation logic in the constructor executes against an object whose fields the subclass has not yet copied. Keep constructors free of advised calls.
If you genuinely need field interception, constructor advice, or advice on non-Spring objects, you need real AspectJ with load-time or compile-time weaving. That is a significant step up in build complexity; for the cross-cutting concerns most applications have, proxies are enough.
A worked example: retry with backoff
@Aspect
@Component
@Order(20)
public class RetryAspect {
private static final Logger log = LoggerFactory.getLogger(RetryAspect.class);
@Around("@annotation(retry)")
public Object retry(ProceedingJoinPoint pjp, Retry retry) throws Throwable {
Throwable last = null;
for (int attempt = 1; attempt <= retry.maxAttempts(); attempt++) {
try {
return pjp.proceed();
} catch (Throwable ex) {
if (!isRetryable(ex, retry.on())) throw ex;
last = ex;
log.warn("attempt {}/{} of {} failed: {}",
attempt, retry.maxAttempts(),
pjp.getSignature().getName(), ex.toString());
Thread.sleep(retry.backoffMillis() * (1L << (attempt - 1)));
}
}
throw last;
}
private boolean isRetryable(Throwable ex, Class<? extends Throwable>[] on) {
return Arrays.stream(on).anyMatch(type -> type.isInstance(ex));
}
}Before shipping this, check whether Resilience4j already does it — it does, with circuit breaking, bulkheads and metrics included. Write the aspect when you need behaviour no library provides; otherwise use the library and spend the aspect budget elsewhere.
What to take away
Spring AOP is a small, sharp tool. It advises method calls that go through a proxy, and that single sentence explains every surprising thing it does. Use annotation pointcuts, set explicit orders, keep advice bodies cheap, and remember that an internal call is invisible to the proxy.
Frequently Asked Questions
Why does my @Transactional method do nothing when called from the same class?
Does Spring use JDK or CGLIB proxies?
What is the performance cost of an aspect?
Related tutorials
- Configuration & Profiles MasteryType-safe configuration with @ConfigurationProperties, the full property precedence order, relaxed binding rules, profile groups, and keeping secrets out of your YAML.
- Actuator & Observability EndpointsEvery Actuator endpoint worth exposing, writing custom health indicators for Kubernetes probes, adding Micrometer metrics that answer real questions, and securing it all.
- Spring IoC Container InternalsThe Spring container from the inside: the full bean lifecycle in order, what BeanPostProcessor actually intercepts, every bean scope, and how circular dependencies are resolved.
- Spring Boot Testing MasterclassA test strategy that stays fast: when to use @SpringBootTest versus a slice, real databases with Testcontainers and @ServiceConnection, stubbing HTTP with WireMock, and context caching.