Spring AOP – Aspects, Advice and Proxies

Spring Advanced 4 min read Published
In this lesson

Some code belongs everywhere: “log how long this took”, “check the user is an admin”, “record who changed this”. Copying it into every method buries your business logic. Aspect-Oriented Programming (AOP) lets you write it once and apply it around many methods.

Cross-cutting concerns#

public Order placeOrder(Order o) {
    long start = System.nanoTime();                 // timing
    log.info("placeOrder called by {}", user());    // logging
    if (!security.canOrder(user())) throw ...;      // security
    try {
        tx.begin();                                 // transactions
        // ↓ the only line that is actually about orders
        Order saved = repo.save(o);
        tx.commit();
        return saved;
    } catch (Exception e) { tx.rollback(); throw e; }
    finally { metrics.record(System.nanoTime() - start); }
}

With AOP, the method shrinks to its real job, and the rest is applied by annotations or rules.

Vocabulary#

TermMeaning
Aspecta class containing cross-cutting logic (@Aspect)
Advicethe code to run, and when: @Before, @After, @AfterReturning, @AfterThrowing, @Around
Join pointa point in execution, in Spring always a method call on a bean
Pointcutan expression selecting join points (which methods)
Proxythe wrapper object Spring creates to run advice around the real bean

A timing aspect#

package com.vitalstack;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed { }
package com.vitalstack;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimingAspect {

    @Around("@annotation(com.vitalstack.Timed)")              // pointcut: methods annotated @Timed
    public Object time(ProceedingJoinPoint jp) throws Throwable {
        long start = System.nanoTime();
        try {
            return jp.proceed();                                // call the real method
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.println("[timed] " + jp.getSignature().toShortString() + " took ~" + (ms / 100 * 100) + " ms");
        }
    }

    @AfterThrowing(pointcut = "execution(* com.vitalstack.ReportService.*(..))", throwing = "ex")
    public void logError(Exception ex) {
        System.out.println("[error] " + ex.getMessage());
    }
}
package com.vitalstack;

import org.springframework.stereotype.Service;

@Service
public class ReportService {
    @Timed
    public String monthly() throws InterruptedException {
        Thread.sleep(300);                                      // pretend to work
        return "report ready";
    }

    public void broken() {
        throw new IllegalStateException("no data for this month");
    }

    public String selfCall() throws InterruptedException {
        return monthly();                                       // calls itself: NOT through the proxy
    }
}
package com.vitalstack;

import org.springframework.context.annotation.*;

@Configuration
@ComponentScan
@EnableAspectJAutoProxy                                         // Spring Boot enables this automatically
public class Main {
    public static void main(String[] args) throws Exception {
        try (var ctx = new AnnotationConfigApplicationContext(Main.class)) {
            ReportService reports = ctx.getBean(ReportService.class);
            System.out.println("bean class: " + reports.getClass().getSimpleName());
            System.out.println(reports.monthly());
            try { reports.broken(); } catch (IllegalStateException ignored) { }
            System.out.println("self call: " + reports.selfCall());
        }
    }
}

Output:

bean class: ReportService$$SpringCGLIB$$0
[timed] ReportService.monthly() took ~300 ms
report ready
[error] no data for this month
self call: report ready

ReportService itself never mentions timing or error logging.

How it works: proxies#

The bean you receive isn’t your ReportService. It’s a generated proxy subclass (ReportService$$SpringCGLIB$$0). Calls go through the proxy, which runs the advice and then calls the real method:

caller ──▶ [proxy: advice before] ──▶ real ReportService.monthly() ──▶ [proxy: advice after] ──▶ caller

Spring uses JDK dynamic proxies for interfaces and CGLIB subclasses for classes. This is how @Transactional, @Async, @Cacheable, @PreAuthorize and @Retryable all work.

The self-invocation trap#

Look at the last output line: selfCall() called monthly(), but no timing was printed. Inside the object, this.monthly() calls the real method directly, bypassing the proxy. The same trap breaks @Transactional on internal calls (next lesson).

Fixes: move the method to another bean (cleanest), or inject the bean into itself lazily.

Other proxy limits: advice doesn’t apply to private or final methods, or to objects you create with new.

Pointcut expressions#

ExpressionMatches
execution(* com.vitalstack.service.*.*(..))any method of any class in the service package
execution(public * *(..))any public method
within(com.vitalstack..*)anything in the package and sub-packages
@annotation(com.vitalstack.Timed)methods with the annotation
bean(*Service)beans whose name ends with Service

Which advice?#

AdviceRunsUse for
@Beforebefore the methodsecurity checks, logging inputs
@AfterReturningafter success (can read the result)auditing, metrics
@AfterThrowingafter an exceptionerror logging, alerts
@Afteralways (like finally)cleanup
@Aroundwraps the call; must call proceed()timing, retries, caching, transactions

Use AOP for truly cross-cutting concerns. For regular business logic, plain method calls are easier to read and debug. Too much “magic” makes code hard to follow.

Exercise#

  1. Write an @AuditLog annotation and an aspect that prints the method name and arguments before each annotated method.
  2. Write an @Around retry aspect that retries a method up to 3 times when it throws IOException.
  3. Fix the self-invocation in ReportService by moving monthly() into a separate MonthlyReport bean.
Hint for 1

@Before("@annotation(com.vitalstack.AuditLog)") public void log(JoinPoint jp) { System.out.println(jp.getSignature().getName() + " " + Arrays.toString(jp.getArgs())); }

Interview corner#

  • What is AOP? A way to modularise cross-cutting concerns (logging, security, transactions) into aspects applied around methods.
  • How does Spring implement AOP? With runtime proxies (JDK dynamic proxies or CGLIB subclasses) around beans.
  • Advice types? Before, AfterReturning, AfterThrowing, After (finally), Around.
  • Why doesn’t advice run on internal method calls? Self-invocation calls the target directly, bypassing the proxy.

Summary#

  • Aspects hold cross-cutting code; pointcuts choose methods; advice says when it runs.
  • Spring wraps beans in proxies; calls through the proxy get the advice.
  • Self-invocation, private and final methods bypass proxies.

Next: @Transactional, the most important aspect of all.

Quick quiz

3 questions. Check what stuck and earn 2 XP for each right answer.

1How does Spring apply aspects?
2Why doesn't advice run when a method calls another method of the same class?
3Which advice wraps the whole call and must call proceed()?

Progress is saved in this browser. Create a free account to keep it on every device.