← Back to the section

This topic scares people with its name, but the idea behind it is simple. Let's start from scratch: what pain AOP solves, how Spring quietly swaps out your objects, and why that swap makes @Transactional and @Cacheable sometimes "not work" even though they're written correctly.

Why we need AOP at all

Imagine you decided to log every call to your important methods: the method name, its arguments, how long it ran. The old way to do this was to add the same snippet into every method:

public Order create(CreateOrderCommand cmd) {
    log.info("→ create, args={}", cmd);   // the same thing every time
    long start = System.currentTimeMillis();
    // ... the real work ...
    log.info("← create, took={}ms", System.currentTimeMillis() - start);
    return order;
}

The pain is obvious: this code is repeated in dozens of methods, mixes in with the business logic, and if you ever want to change the log format you have to edit it everywhere. The same goes for measuring metrics, checking permissions, wrapping things in a transaction.

Things like this — logging, metrics, security, transactions — are called cross-cutting concerns: they run "across" the whole codebase, are needed in many places, but have nothing to do with the actual point of the methods.

AOP (Aspect-Oriented Programming) is a way to move such cross-cutting logic into one place and apply it to many methods automatically, without touching the methods themselves. The method stays clean, and the "wrapping" lives separately.

The main thing to grasp right away: you already use AOP, even if you've never written an aspect by hand. @Transactional, @Async, @Cacheable, @Scheduled, permission checks in Spring Security — all of it works precisely through AOP. That's why understanding the mechanics pays off: without it, it's unclear why these annotations sometimes stay silent.

Four words you need to know

AOP has just a handful of terms. Let's explain them with an analogy of a security guard in a building.

  • Aspect — the module with the cross-cutting logic itself. It's like the role of "security guard": a set of rules that get applied in different places.
  • Advice — what exactly to do. "Log who came in." This is the code that will run around your method.
  • Join point — a place where advice could, in principle, fire. In Spring this is always a method call — neither fields nor constructors are intercepted.
  • Pointcut — the selection rule: which methods exactly to apply to. "Check everyone entering through the main entrance," not every single person in the city.

In short: aspect = "pointcut (where) + advice (what to do)".

How Spring does it: proxies

Here's the key idea, without which nothing makes sense. When you mark a method with something like @Transactional, Spring does not change your class. Instead of your object it hands out a different wrapper object — a proxy.

An analogy: you call a company, but a receptionist picks up. The receptionist logs the call, checks that you're allowed to be connected, and only then forwards the call to the right employee. From the outside it looks like you're talking to the employee directly — but there's always an intermediary between you.

A proxy works the same way: it intercepts the method call, runs the cross-cutting logic (open a transaction, write a log, check the cache), and then calls your real method. When another bean asks Spring for OrderService, it doesn't get the OrderService itself, it gets its proxy.

Almost all of AOP's further behaviour follows from this — keep the intermediary picture in mind.

A simple example: a logging aspect

Let's build an aspect that logs all handle(...) methods in the usecase package:

@Aspect
@Component
@Slf4j
public class MethodLoggingAspect {

    @Pointcut("execution(* com.example.app.usecase..*.handle(..))")
    public void useCaseMethods() {}

    @Around("useCaseMethods()")
    public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
        String name = pjp.getSignature().toShortString();
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();   // call the real method
            log.info("{} took {}ms", name, System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.error("{} threw {}", name, t.getClass().getSimpleName());
            throw t;
        }
    }
}

Let's break it down piece by piece:

  • @Aspect — tells Spring "this is an aspect"; @Component — so Spring finds it at startup.
  • @Pointcut(...) — describes which methods to apply to. The execution(...) expression reads like this: any method named handle, with any return type and any arguments, in the usecase package and nested ones.
  • @Around — this is the advice: the code runs around the method. pjp.proceed() is the moment control passes into your real method.

The Handler itself knows nothing about this aspect — it stays clean. All of this turns on automatically: in Spring Boot, AOP is active out of the box.

Kinds of advice — what you can do around a method

There are five kinds of advice, differing by when they fire:

  • @Before — before the method. For example, to check permissions. You can't change the arguments or the result.
  • @AfterReturning — after a successful completion. You can see the result the method returned.
  • @AfterThrowing — if the method threw an error. You can log it.
  • @After — after completion in any case, success or error (like finally).
  • @Around — wraps the whole call: you can swap arguments, catch an error, replace the result, measure time. The most powerful one, and the most common for non-trivial tasks.

In practice, if the task is harder than a simple log, people reach for @Around — it can do everything the others can.

Your own annotation + an aspect — a common trick

A handy pattern: define your own marker annotation and a single aspect that reacts to it. Then attaching the behaviour to a method is just a matter of hanging the annotation on it.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    String action();
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditService audit;

    @Around("@annotation(audited)")            // fires on methods with @Audited
    public Object record(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
        try {
            Object result = pjp.proceed();
            audit.log(audited.action(), "ok");
            return result;
        } catch (Throwable t) {
            audit.log(audited.action(), "failed");
            throw t;
        }
    }
}

@Service
public class OrderService {
    @Audited(action = "create-order")
    public OrderId create(CreateOrderCommand cmd) { ... }
}

Now any method marked with @Audited will automatically land in the audit log. This is exactly the same mechanism that the built-in @Transactional works by.

Two kinds of proxy: JDK Proxy and CGLIB

Spring creates the proxy (that same "receptionist") in two ways. Knowing the difference matters, because the limitations grow out of it.

  • JDK Dynamic Proxy — works only if your class implements an interface. The proxy pretends to be that interface and holds the real object inside.
  • CGLIB — creates a subclass of your class on the fly and overrides its methods. No interface is needed here.

Starting with Spring Boot 2.0, CGLIB is used by default for everything. That's more convenient: you don't have to guess whether there's an interface or not.

But since a proxy is a subclass that overrides methods, limitations appear. You can't override what can't be overridden:

  • final methods aren't proxied — a subclass can't override them.
  • final classes aren't proxied at all — you can't subclass them.
  • private methods are invisible to the proxy — they aren't inherited.

Hence a simple rule: put @Transactional, @Async, @Cacheable on public methods that aren't marked final, in classes that aren't final. Otherwise the annotation silently does nothing.

Why @Transactional sometimes doesn't fire (self-invocation)

This is the most common and most insidious AOP trap. Back to the receptionist analogy: only calls coming from outside get intercepted. If an employee inside the building calls a colleague on the internal line — the receptionist doesn't know about it and logs nothing.

It's the same with proxies. The proxy intercepts only calls that come from outside the object. A method call through this goes past the proxy — straight inside the real object:

@Service
public class OrderService {

    public void batch() {
        this.processOne();   // a call through this — past the proxy!
    }

    @Transactional
    public void processOne() { ... }   // the transaction WON'T open
}

When batch() calls this.processOne(), the wrapper proxy is no longer involved at that moment — we're already inside the real object. So @Transactional on processOne() is simply ignored. The same happens with @Cacheable, @Async and any aspect — it's proxy behaviour, not a bug in a particular annotation.

Here's what a call "from outside" and "from inside" looks like:

diagram

How to fix it:

  • Split the methods across different beans — let batch() call processOne() on another service. Then the call again comes from outside, through the proxy. This is the cleanest path.
  • If you really must keep them in one class — inject a reference to the object itself via ObjectProvider<OrderService> and call provider.getObject().processOne(). That's already a call through the proxy, but it looks like a workaround, so people more often go with the first option.

The remaining limitations of Spring AOP

Besides self-invocation, keep three more boundaries in mind:

  • Only Spring beans. An aspect won't fire on an object you created with new by hand. Spring wraps only the objects it manages itself in a proxy.
  • Only method calls. Fields, constructors and static methods aren't intercepted by Spring AOP. What gets intercepted is precisely the moment an ordinary method is called.
  • The ordering of multiple aspects. If several aspects are attached to one method (for example, audit and timing), their order is set by the @Order annotation — a smaller number means "on the outside, closer to the caller".
@Aspect @Component @Order(1)
public class AuditAspect { ... }    // runs first (on the outside)

@Aspect @Component @Order(2)
public class TimedAspect { ... }    // inside the audit

If order matters — set @Order explicitly, don't leave it to chance.

When to write your own aspect, and when not to

People write their own aspects by hand less often than it seems. Usually the built-in ones are enough:

  • transaction — @Transactional;
  • cache — @Cacheable;
  • method-level permission check — @PreAuthorize (Spring Security uses AOP itself);
  • timing metrics — usually it's easier to reach for the ready-made tools of Micrometer than to write an aspect.

Your own aspect is a good choice when you need a single behaviour for many methods marked with your own tag (like the @Audited example). But if the behaviour is needed in just one or two places — it's simpler to write it right there in the code, without an aspect: less magic, easier to read.

In short

  • AOP moves cross-cutting logic (logs, metrics, permissions, transactions) into one place and applies it to many methods without touching the methods themselves.
  • Terms: aspect = pointcut (where to apply) + advice (what to do); the point of application in Spring is always a method call.
  • Spring works through proxies — it swaps your object for a "receptionist" wrapper that intercepts calls.
  • There are five kinds of advice; the most powerful is @Around, which wraps the whole call.
  • The proxy is made by CGLIB by default (a subclass of the class); that's why @Transactional and the like only work on public, non-final methods of non-final classes.
  • Self-invocation: a call through this goes past the proxy, so @Transactional/@Cacheable on such a method stay silent — move the method into another bean.
  • Spring AOP works only with Spring beans and only on method calls; the order of multiple aspects is set by @Order.
  • Often you don't need your own aspect — the built-in @Transactional, @Cacheable, @PreAuthorize already cover the typical tasks.
  • @Transactional in detail — the main example of AOP with all its subtleties.
  • DI/IoC, bean lifecycle, scopes — where in a bean's life the proxy is created.
  • Spring Security — permission checks through AOP.