Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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.
  • @Around is 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

TermMeaning
Join pointA point where advice can apply. In Spring AOP, always a method execution.
PointcutAn expression selecting join points.
AdviceThe code to run at a matched join point.
AspectA class combining pointcuts and advice.
WeavingLinking aspects to targets. Spring does it at runtime with proxies.

How the proxy works

Advice runs only for calls that pass through the proxy. The dotted internal call never leaves the target object.

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:

  1. Move the method to a different bean. Usually the cycle was telling you the class does two things.
  2. Inject a self-reference and call through it.
  3. AopContext.currentProxy(), which requires exposeProxy = true and couples your code to Spring.
SelfReference.java
@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

AuditAspect.java
@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.

Audited.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    String value() default "";
}

To read the annotation's own attributes, bind it as a parameter:

ReadingAnnotationAttributes.java
@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.

Aspect nesting. A lower @Order value wraps everything with a higher one.

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.
  • private and static methods 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

RetryAspect.java
@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?
Spring AOP is proxy-based. The proxy wraps the bean, so annotations only apply to calls that arrive through the proxy from outside. An internal call uses this directly and bypasses the proxy entirely. Move the method to another bean, inject a self-reference, or switch to AspectJ load-time weaving.
Does Spring use JDK or CGLIB proxies?
Since Spring Boot 2.0 the default is CGLIB for everything, even when the class implements interfaces, because it avoids a whole class of ClassCastException when injecting by concrete type. CGLIB subclasses the target, so the class and the methods you want advised cannot be final.
What is the performance cost of an aspect?
A proxy call is one extra virtual dispatch plus the advice body — nanoseconds. The cost that matters is what you put in the advice. Logging every argument of a hot method with reflection, or opening a database connection in a @Before, will dominate. Keep advice bodies cheap.

Related tutorials