Spring AOP lets you write processing that’s “common across the board” — like logging or permission checks — separately from your business logic. This article explains the concept of Spring AOP in plain terms, then covers how to add it to a Spring Boot project and the basics of writing it, with sample code.

To begin with, AOP (Aspect-Oriented Programming) is a programming approach that separates processing shared across multiple features — such as logging or permission checks (known as cross-cutting concerns) — from the core processing it’s attached to. In Java, you can achieve AOP with tools like AspectJ even without Spring, but if you’re already using Spring, you can easily adopt this approach through Spring AOP.

Spring AOP in one sentence

Spring AOP is a mechanism that lets you insert “additional processing” before and after a method runs.

For example, the following kinds of processing tend to be needed across many features:

  • Logging when and which method was called
  • Measuring execution time
  • Allowing only authorized users through
  • Applying common error handling when something fails

Writing this kind of processing into every method clutters your code. With Spring AOP, you can consolidate the shared processing in one place and apply it only where needed.

Terminology to learn first

The terminology may look like a lot at first, but the meaning is simple.

  • Aspect A class that consolidates shared processing. It’s the “organizer” for things like logging or measurement.
  • Pointcut The condition for where to apply the aspect. It narrows things down to something like “only this method in this package.”
  • Advice The actual processing being inserted. There are types such as “run before” and “run after.”

With Spring AOP, it’s easiest to understand if you remember that, fundamentally, you’re inserting processing at “the moment a method is called.”

Making it available in Spring Boot

This article assumes Spring Boot 3.x (Spring Framework 6.x, Java 17 or later).

With Spring Boot, adding the dependency is basically all the setup you need.

Adding the dependency

For Gradle:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-aop'
}

For Maven:

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

Let’s measure execution time and log it

Here, we’ll add measurement only to the places where we want to measure time.

Create a marker annotation

We’ll measure only methods with this annotation attached.

package com.example.demo.aop;

import java.lang.annotation.*;

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

Write the aspect

Adding @Aspect tells Spring “this is an AOP class.” @Around is often used when you want to insert processing both before and after.

package com.example.demo.aop;

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

@Aspect
@Component
public class TimingAspect {

  private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

  @Around("@annotation(com.example.demo.aop.Timed)")
  public Object measure(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();

    try {
      // Execute the original method here
      return pjp.proceed();
    } finally {
      long elapsedMs = (System.nanoTime() - start) / 1_000_000;
      log.info("method={} elapsedMs={}", pjp.getSignature().toShortString(), elapsedMs);
    }
  }
}

The key points are here:

  • Calling pjp.proceed() inside @Around executes the original method
  • Code written before proceed() becomes “before” processing; code written after becomes “after” processing

Attach it to the method you want to use

package com.example.demo.service;

import com.example.demo.aop.Timed;
import org.springframework.stereotype.Service;

@Service
public class GreetingService {

  @Timed
  public String hello(String name) {
    return "Hello " + name;
  }
}

Now, every time hello is called, its execution time gets logged. The nice part is that you don’t need to write any “measurement code” in your business logic.

Types of insertion timing

You only need to remember the commonly used ones.

  • @Before Runs before the method
  • @AfterReturning Runs after the method finishes normally
  • @AfterThrowing Runs after the method ends with an exception
  • @After Runs at the end regardless of success or failure
  • @Around Lets you write both before and after processing together

When in doubt, @Around is often the choice, since you can write both before and after logic and still return the result as-is.

Logging only on exceptions

If you only want to log when something fails, @AfterThrowing is easier to read.

@AfterThrowing(
  pointcut = "execution(* com.example.demo..*Service.*(..))",
  throwing = "ex"
)
public void logError(Exception ex) {
  log.warn("service error", ex);
}

That said, if you also want to shape the error response returned to the client in a REST API, it’s more straightforward to lean on @RestControllerAdvice rather than AOP. An implementation that includes stack trace logging and attaching a trace ID is covered in Implementing Spring Boot’s GlobalExceptionHandler for Production Use.

How to specify where to apply it

There are broadly two approaches:

  • Specifying locations with string-based rules
  • Specifying locations with annotations

Specifying with string-based rules

The execution syntax lets you specify patterns for return types and arguments as well. For example, to narrow things down to only methods that return String, you’d write it like this:

@Around("execution(String com.example.demo..*Service.*(..))")

You can also specify the number and types of arguments. (..) means “any arguments,” while something like (String, *) means “the first argument is a String, the second can be anything.” This AspectJ-style pointcut notation is commonly used across Java AOP in general, not just Spring AOP, so it’s useful knowledge when working with other AOP frameworks too.

For example, if you want to target “all methods of classes ending in Service,” you can write it as follows:

@Around("execution(* com.example.demo..*Service.*(..))")
public Object aroundServices(ProceedingJoinPoint pjp) throws Throwable {
  return pjp.proceed();
}

However, if the scope is too broad, it can end up “taking effect in places you didn’t expect.” It’s recommended to start with a narrow scope and test it out.

Specifying with annotations

In practice, you often want it to “take effect only where you’ve attached it,” so annotation-based specification is quite convenient.

@Around("@annotation(com.example.demo.aop.Timed)")
public Object timedOnly(ProceedingJoinPoint pjp) throws Throwable {
  return pjp.proceed();
}

Combining pointcuts to narrow things down

Conditions can be chained with && or ||. It’s enough to remember these four commonly used designators:

  • execution Specifies by method signature. The most commonly used
  • within Specifies by class or package scope
  • @annotation Narrows down to only methods with a given annotation attached
  • bean Specifies by bean name. Wildcards like bean(*Service) can also be used

For example, to target “only methods within the service package that also have @Timed attached,” you’d write it as follows:

@Around("within(com.example.demo.service..*) && @annotation(com.example.demo.aop.Timed)")

If you reuse the same condition across multiple pieces of advice, naming it with @Pointcut makes it easier to read.

@Aspect
@Component
public class ServiceAspect {

  private static final Logger log = LoggerFactory.getLogger(ServiceAspect.class);

  @Pointcut("execution(* com.example.demo..*Service.*(..))")
  public void serviceMethods() {}

  @Before("serviceMethods()")
  public void logStart(JoinPoint jp) {
    log.info("start {}", jp.getSignature().toShortString());
  }

  @AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
  public void logError(Exception ex) {
    log.warn("service error", ex);
  }
}

By keeping the condition in one place, you only need to edit the contents of @Pointcut when widening or narrowing the target.

Working with arguments and return values via ProceedingJoinPoint

Deciding execution order when using multiple Aspects

When multiple aspects apply to a single method, you’ll naturally care about execution order. To make the order explicit, attach @Order to the aspect class.

@Aspect
@Component
@Order(1)
public class LoggingAspect {
  // ...
}

Aspects with a smaller @Order value run first. For @Around, think of it as: the smaller the number, the more “outer” the aspect sits. If you don’t specify an order, the execution order between aspects isn’t guaranteed, so for cases where order affects the outcome — such as logging versus authorization checks — it’s safer to specify it explicitly.

The ProceedingJoinPoint passed into @Around has uses beyond just “executing the original method.”

  • getSignature() Retrieves the class name and method name. Useful for building log messages
  • getArgs() Receives the call-time arguments as an array
  • proceed(Object[] args) Executes the original method with replaced arguments

For example, you could trim leading and trailing whitespace from a string argument before passing it on to the original processing.

@Around("@annotation(com.example.demo.aop.Trimmed)")
public Object trimArgs(ProceedingJoinPoint pjp) throws Throwable {
  Object[] args = pjp.getArgs();
  for (int i = 0; i < args.length; i++) {
    if (args[i] instanceof String s) {
      args[i] = s.trim();
    }
  }
  return pjp.proceed(args);
}

You can also process the result of proceed() before returning it. That said, advice that rewrites arguments or return values tends to become “magic invisible to the caller,” so make sure to share this with your team when you use it.

Spring AOP works through proxies

Now that we’ve covered the syntax, understanding one thing about how it works will make the pitfalls in the next section click into place.

Spring AOP doesn’t register the Bean targeted by an aspect as-is; instead, it registers a “proxy” that wraps that Bean in its place. When another Bean calls it, the proxy receives the call first, runs the advice, and then calls the real method inside. In other words, advice only runs for “calls that go through the proxy.”

There are two ways a proxy can be created:

  • JDK Dynamic Proxy Creates a proxy based on an interface. Can only be received as the interface type
  • CGLIB Creates a proxy as a subclass that extends the class. Works even without an interface, but has no effect on final classes or methods

In Spring Boot, the default for spring.aop.proxy-target-class is true, so CGLIB is used unless configured otherwise. This is why “AOP works even on a Service that doesn’t implement an interface.”

Difference from AspectJ

“Spring AOP” and “AspectJ” are easily confused, but they’re different things.

  • Spring AOP Creates a proxy at runtime and inserts behavior through it. Only applies to method calls on Beans managed by Spring. Works with no extra configuration
  • AspectJ Rewrites bytecode at compile time or class-load time (weaving). Can affect classes and field access and constructors beyond just Beans. Requires a dedicated compiler or agent configuration

Spring AOP only borrows AspectJ’s annotations like @Aspect and @Around and its pointcut notation — the underlying mechanism is proxies. Adding spring-boot-starter-aop also pulls in aspectjweaver, but this is only used for interpreting annotations and pointcut expressions — it doesn’t enable weaving.

For a typical web application, Spring AOP is sufficient. It’s only worth considering AspectJ once you have a requirement to affect classes beyond Beans.

Common pitfalls

Spring AOP is convenient, but there are points where its underlying mechanism can trip you up.

Calling within the same class may not trigger it

When calling another method within the same class, AOP sometimes doesn’t take effect.

public void a() {
  this.b(); // calling it this way may not trigger AOP
}

Two common countermeasures are:

  • Extract the method into a separate class so the call goes between Beans
  • Design around it without relying on AOP

It helps to remember that “AOP only takes effect through calls to Beans managed by Spring” — that makes the cause easier to spot.

Attaching it to a private method doesn’t work as expected

It’s safest to think of AOP as something that fundamentally applies to “methods called from outside.” Attaching it to the public methods that serve as a service’s entry point is the safe approach.

Transactions work on a similar mechanism

@Transactional also operates internally on a similar idea. If you swallow or absorb exceptions along the way, you can end up with results that differ from what you expect.

When logging exceptions with AOP, keeping in mind “whether to rethrow the exception as-is” reduces the chance of mishaps.

When to use AOP, and when not to

The idea of extracting cross-cutting concerns with AOP also pairs well with designs that make boundaries between modules explicit. If you want to separate responsibilities by package and verify dependencies, a modular monolith structure using Spring Modulith is also an option. How to define and verify boundaries is covered in How to Build a Modular Monolith with Spring Modulith.

It’s a convenient mechanism, but pushing everything onto AOP can actually make code harder to read. In practice, the following criteria help you avoid mistakes when deciding:

When to use it

  • Cross-cutting concerns like logging, measurement, and auditing
  • Security checks and common exception handling
  • Cases where the same pre/post processing is needed across multiple modules

When it’s better not to use it

  • Business rules themselves (e.g., price calculation, inventory allocation)
  • Processing where execution order is complex and explicit calls are easier to read
  • Core domain processing where you want to strictly track the scope of impact

Keeping domain logic in regular code and cross-cutting processing in AOP leads to cleaner separation of responsibilities.

Connections between business flows, like “send an email once registration is complete,” are also easier to trace later if made explicit as events rather than hidden away in AOP. This design is covered in How to Decouple Modules with Spring Boot’s ApplicationEvent.

Notes for production use

  • Don’t make pointcuts too broad from the start (to avoid unintended matches)
  • Don’t output PII (personal information) in logs
  • Don’t swallow exceptions in @Around
  • For load measurement purposes, control log volume (e.g., via sampling)

In particular, a pointcut that “applies to all Services at once” is convenient, but since side effects become harder to see, it’s recommended to widen the scope gradually.

Summary

Spring AOP is a mechanism that lets you write common processing, such as logging and measurement, separately from your business logic. With Spring Boot, you can get started just by adding the starter.

It’s easiest to understand by first trying the approach of creating a custom annotation so it “only takes effect where you’ve attached it.” Once you’re comfortable with it, gradually widening the scope of application is recommended.