Spring Boot – Aspect-Oriented Programming

June 25, 20265 min readUpdated 8/18/2026

Some behaviour does not belong to any one method but is wanted in forty of them: timing, audit logging, security checks, transaction boundaries. Copying it into each is how the business logic disappears under plumbing, and how the forty-first method ends up without it. AOP moves that behaviour into one place.

This lesson also explains the proxy rule, which is the single most useful thing in this whole track — because @Transactional, @Cacheable, @Async, @Retryable and @PreAuthorize are all built on the same mechanism and all fail in the same silent way.

Setup

<!-- NOTE THE ARTIFACT NAME. Spring Boot 3 called this spring-boot-starter-aop.
     Boot 4 renamed it to spring-boot-starter-aspectj, and the old name is not
     in the BOM at all - so a Boot 3 pom fails with "version is missing" rather
     than with anything that mentions the rename. -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>

No @EnableAspectJAutoProxy is needed — Boot auto-configures it once the starter is present.

The vocabulary, in one example

Here is the pizza API's timing aspect in full. Every AOP term is visible in it:

@Slf4j
@Aspect
@Component
public class ServiceTimingAspect {

    private static final long SLOW_CALL_MILLIS = 250;

    @Pointcut("execution(* com.pizza.api.entity..*ServiceImpl.*(..))")
    public void serviceLayer() {}

    @Pointcut("execution(* com.pizza.api.report.*ServiceImpl.*(..))")
    public void reportLayer() {}

    @Around("serviceLayer() || reportLayer()")
    public Object time(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        String target = joinPoint.getSignature().toShortString();

        try {
            return joinPoint.proceed();
        } catch (Throwable ex) {
            long millis = (System.nanoTime() - start) / 1_000_000;
            log.warn("{} failed after {} ms: {}", target, millis, ex.toString());
            throw ex;
        } finally {
            long millis = (System.nanoTime() - start) / 1_000_000;
            if (millis >= SLOW_CALL_MILLIS) {
                log.warn("SLOW {} took {} ms", target, millis);
            } else if (log.isDebugEnabled()) {
                log.debug("{} took {} ms", target, millis);
            }
        }
    }
}
TermWhat it is here
Aspectthe class — a module of cross-cutting behaviour
Join pointa method call that could be intercepted
Pointcutthe expression selecting which ones
Advicetime(…) — the code that runs
WeavingSpring wrapping the bean in a proxy at startup

Reading a pointcut

execution(* com.pizza.api.entity..*ServiceImpl.*(..))
//        ▲ ▲                      ▲            ▲ ▲
//        │ │                      │            │ └── any arguments
//        │ │                      │            └──── any method name
//        │ │                      └─────────────── class name ends "ServiceImpl"
//        │ └────────────────────────────────────── package and sub-packages (..)
//        └──────────────────────────────────────── any return type

Note the difference between . and ..: one dot is a literal separator, two dots means "this package and everything below it". A pointcut that silently matches nothing is almost always a . that should have been ...

Other useful forms:

// Every method in classes annotated with @RestController
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
public void controllers() {}

// Every method annotated with a specific annotation
@Pointcut("@annotation(com.pizza.api.aspect.Audited)")
public void audited() {}

// Combine them
@Around("controllers() && !audited()")

Named pointcuts are worth the extra lines. The expression is declared once, and the advice then reads as prose instead of as a regex.

The five advice types

AdviceRunsCan it stop the call?
@Beforebeforeonly by throwing
@AfterReturningafter successno
@AfterThrowingafter an exceptionno
@Afteralways, like finallyno
@Aroundwraps the callyes

@Around is the only one that can see the outcome and decide whether the call happens at all. It must call joinPoint.proceed() and return its result — forget, and the method never runs and every caller silently gets null.

Note what the aspect above deliberately does not do: it never swallows the exception. It logs and rethrows. An aspect that quietly turns a failure into a null is how observability code becomes the bug.

⚠️ The proxy rule

Spring implements all of this by wrapping your bean in a proxy. The container hands the proxy to everyone who injects the bean, so calls arriving from another bean pass through the advice.

   another bean ──▶ [ proxy ] ──▶ [ your bean ]
                       ▲                 │
                    advice runs          │ this.b() goes straight here.
                                         └─▶ no proxy, no advice.

Which means:

@Service
public class ProductServiceImpl {

    public void a() {
        b();          // NOT advised - internal call, the proxy is not involved
    }

    @Transactional    // no transaction when called from a()
    public void b() { /* … */ }
}

This is not an AOP quirk. It applies identically to:

  • @Transactional — no transaction, and the write commits outside one
  • @Cacheable — nothing cached, the method just runs every time
  • @Async — runs on the caller's thread, synchronously
  • @Retryable — no retry, the first failure propagates
  • @PreAuthorizeno authorization check at all

Every one of these fails silently. Nothing logs, nothing throws; the behaviour is simply absent. When an annotation "isn't working", a self-invocation is the first thing to check.

The fixes, best first:

  1. Move the annotated method to another bean. Usually the right answer — if b() deserves its own transaction, it is doing its own job.
  2. Inject the interface into itself and call through it, so the call goes via the proxy. Works, and looks strange enough that it needs a comment.
  3. AopContext.currentProxy() — requires exposeProxy = true and is a last resort.

Two related consequences of proxying: private and final methods are never advised (a proxy cannot override them), and by default Spring proxies the interface if there is one, so the bean type you inject should be the interface.

When AOP is the wrong tool

AOP hides behaviour. That is the point, and it is also the cost: someone reading createOrder cannot see that it is being timed. Worth it for genuinely uniform, genuinely cross-cutting concerns — logging, metrics, transactions, security. Not worth it for business rules, where "why did this happen?" should be answerable by reading the method.

Prefer the framework's own annotations where they exist. Do not write a caching aspect; use @Cacheable.

What to take from this

  • Boot 4: spring-boot-starter-aspectj, not -aop.
  • @Around must call proceed() and should rethrow, never swallow.
  • The proxy rule: a self-invocation is never advised, and this silently breaks @Transactional, @Cacheable, @Async, @Retryable and @PreAuthorize too.
  • Use it for cross-cutting concerns, not for business logic.

Next: application events — the other way to decouple, and the transaction-phase mistake that emails customers about orders that were rolled back.