Spring Boot AOP with an AspectJ Annotation Example

Build a Spring Boot 4 AOP aspect that measures order service methods. Run tests showing advice on successful and failed calls, and why self invocation bypasses the Spring proxy.

Spring AOP

Spring AOP runs shared behavior around selected method calls on Spring managed beans. We put that behavior in an aspect instead of repeating it in every service method. A common use is recording how long service operations take. Spring applies the aspect through a proxy, so understanding which calls pass through that proxy matters as much as writing the advice method.

The examples use Spring Boot 4.1.2 and Java 17. They measure two operations in an order service and test both successful and failed calls.

1. How Spring AOP Applies an Aspect

An aspect is a class that groups behavior shared by several methods. Its advice is the method that performs the behavior. A pointcut identifies the methods to which the advice applies. In Spring AOP, the join point is a method execution on a Spring bean.

Spring creates a proxy for a bean whose methods match a pointcut. Code that obtains the bean from Spring calls the proxy, which runs the advice and then invokes the target method. The target method does not have to call the aspect itself.

Part Role in this example
OrderService Spring bean whose methods handle an order
@Measured Marker placed on methods we want to time
TimingAspect Aspect that intercepts marked methods
TimingRecorder Stores method names so tests can check which calls were intercepted

1.1. Add the Spring Boot 4 Dependency

The Spring Boot starter list names spring-boot-starter-aspectj for AspectJ style AOP. It supplies the libraries needed for the annotation and proxy support. Spring Boot enables AspectJ auto proxying when AspectJ is on the classpath, so the application does not need @EnableAspectJAutoProxy.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>

Spring Boot uses class based proxies by default. If the service implements an interface and we want JDK proxies, we can set spring.aop.proxy-target-class=false. Both proxy types share the same call boundary discussed in section 4.

spring.aop.proxy-target-class=false

2. Measure Order Service Methods

Our order application checks an order identifier before creating a receipt. We want timing information for the service operations, but timing should not become part of the checkout rules. The application therefore marks the operations with @Measured and lets the aspect record their execution.

2.1. Mark the Methods to Measure

The annotation needs runtime retention because Spring inspects it while the application runs. It targets methods, so it cannot accidentally mark a class or field.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Measured {}

OrderService is a Spring bean because it has @Service. Both methods are marked so callers can request timing for either operation. Inside checkout(), the call to validate() is an ordinary call on the same instance. That distinction becomes visible in the test.

@Service
public class OrderService {
  @Measured
  public String checkout(String orderId) {
    validate(orderId);
    return "receipt:" + orderId;
  }

  @Measured
  public void validate(String orderId) {
    if (orderId == null || orderId.isBlank()) {
      throw new IllegalArgumentException("orderId is required");
    }
  }
}

2.2. Write the Around Advice

The pointcut expression selects methods annotated with @Measured. @Around advice receives a ProceedingJoinPoint, which represents the intercepted call. Calling proceed() invokes the target method and returns its result. The advice returns that result so callers still receive the receipt from checkout().

@Aspect
@Component
public class TimingAspect {
  private final TimingRecorder recorder;

  public TimingAspect(TimingRecorder recorder) {
    this.recorder = recorder;
  }

  @Around("@annotation(com.howtodoinjava.aop.Measured)")
  public Object time(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.nanoTime();
    try {
      return joinPoint.proceed();
    } finally {
      long elapsed = System.nanoTime() - start;
      recorder.record(joinPoint.getSignature().getName());
      System.out.printf("%s took %d ns%n", joinPoint.getSignature().getName(), elapsed);
    }
  }
}

The finally block records the method even when it throws. A duration includes the target method and any advice nested inside the call. It is diagnostic data, not a benchmark: individual nanosecond values vary between runs. The test checks the intercepted method names instead of asserting a particular duration.

TimingRecorder is a Spring bean with a thread safe list. The complete class is in the linked project. Its record() method adds a name, while methodNames() returns an immutable copy for assertions.

@Component
public class TimingRecorder {
  private final CopyOnWriteArrayList<String> methodNames = new CopyOnWriteArrayList<>();

  public void record(String methodName) {
    methodNames.add(methodName);
  }

  public List<String> methodNames() {
    return List.copyOf(methodNames);
  }

  public void clear() {
    methodNames.clear();
  }
}

2.3. Run the Success and Failure Cases

The test gets OrderService from Spring, so calls to its public methods enter through the proxy. It clears the recorder before each scenario. Calling checkout(“A-17”) produces the receipt and records checkout. A separate call to validate(“A-17”) adds validate to the recorder.

@Autowired OrderService orders;
@Autowired TimingRecorder recorder;

assertThat(orders.checkout("A-17")).isEqualTo("receipt:A-17");
assertThat(recorder.methodNames()).containsExactly("checkout");

orders.validate("A-17");
assertThat(recorder.methodNames()).containsExactly("checkout", "validate");

The failed call still runs the finally block in the advice. The original IllegalArgumentException reaches the caller, and the recorder contains the intercepted method name.

assertThatThrownBy(() -> orders.validate(" "))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessage("orderId is required");
assertThat(recorder.methodNames()).containsExactly("validate");

Run both tests from the project directory with mvn test. They passed on Spring Boot 4.1.2. The console also prints lines such as checkout took 142800 ns; the number is not fixed.

mvn test

3. Choose the Advice That Fits the Work

Around advice is appropriate here because timing needs work before and after the target call, including failures. For a task that needs only one point in the call lifecycle, a narrower advice type is easier to reason about. The Spring advice reference defines when each kind runs.

Advice When it runs Typical use
@Before Before the method Record an attempted operation
@AfterReturning After a normal return Observe a successful result
@AfterThrowing After an exception Record a failure
@After After either outcome Release or record work regardless of outcome
@Around Before and after, or instead of, the method Measure duration or control whether proceed() runs

For example, if we only need to count attempted checkouts, @Before avoids control over the method invocation. It cannot replace the returned receipt or suppress an exception. Use @Around only when the behavior genuinely needs its extra control.

@Before("@annotation(com.howtodoinjava.aop.Measured)")
public void countAttempt(JoinPoint joinPoint) {
  System.out.println("Attempted: " + joinPoint.getSignature().getName());
}

This snippet illustrates the choice; the runnable project uses the tested @Around implementation. Adding both pieces of advice to the same pointcut would run both, so choose based on the required behavior.

4. Understand the Proxy Boundary

Advice runs when the call passes through the Spring proxy. In the test, an external call to orders.checkout() reaches that proxy. The subsequent validate() call is made inside OrderService on this, so it goes directly to the target and does not trigger another timing record. Calling orders.validate() separately reaches the proxy and does trigger advice.

test → Spring proxy → timing advice → OrderService.checkout()
                                      └→ this.validate()  (no second advice)

test → Spring proxy → timing advice → OrderService.validate()

This is Spring AOP’s self invocation limit, documented in the Spring proxying reference. If validation must be advised when checkout invokes it, move validation into another Spring bean and inject that bean into OrderService. The cross bean call then passes through the validation bean’s proxy. AspectJ bytecode weaving has different behavior, but this example uses Spring’s proxy based AOP.

The same principle explains why constructing a service with new OrderService() outside Spring would bypass its aspect. Obtain advised services from the container through injection.

5. Frequently Asked Questions

5.1. Do We Need @EnableAspectJAutoProxy in Spring Boot?

No. With the AspectJ starter on the classpath, Spring Boot configures auto proxying. The aspect must still be a Spring bean, which @Component provides on TimingAspect, and its pointcut must match a method call on a Spring bean. The complete aspect in section 2.2 runs without @EnableAspectJAutoProxy.

5.2. Why Does a Marked Method Have No Timing Record?

Check how the method was called. An internal call through this bypasses the proxy, as the checkout() test demonstrates. Also confirm that the object came from Spring and that the aspect is registered as a bean. A method that never matches the pointcut will not be intercepted.

orders.validate("A-17"); // Spring injected bean: advice runs
// Inside OrderService.checkout(): validate(orderId) bypasses the proxy

5.3. Does @Aspect Mean We Are Using AspectJ Weaving?

No. Spring supports AspectJ annotation syntax for declaring aspects and pointcuts while applying this example through Spring AOP proxies. AspectJ compile time or load time weaving changes bytecode and has a different call boundary. Choose weaving only when proxy based interception does not meet the requirement.

6. Conclusion

For a Spring Boot service, add the AspectJ starter, make the aspect a Spring bean, and choose an advice type that matches the behavior needed. The timing example uses @Around so it can record both successful and failed calls without changing the service result. When advice seems missing, first check whether the call entered through a Spring proxy.

Happy Learning!

Complete Spring Boot AOP example on GitHub

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.