Sitemap
devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.

9 min readOct 8, 2026

--

Spring AOP Explained: Aspects, Pointcuts, and How Proxies Really WorkYou add @Transactional to a method. Sometimes the transaction works perfectly. Sometimes — apparently identical code — it silently does nothing and you spend an afternoon staring at it. The difference is almost never the annotation. It's the proxy.

Spring AOP (Aspect-Oriented Programming) lets you inject cross-cutting behavior — transactions, security, logging, metrics, retries — around your business methods without polluting them. @Transactional, @Cacheable, @Async, @PreAuthorize, and @Retryable are all built on it. Understanding how it works under the hood is the single highest-leverage piece of Spring knowledge, because it explains an entire class of "why didn't my annotation fire?" bugs.

This article covers the proxy mechanism (JDK dynamic proxies vs CGLIB), the @Aspect programming model, pointcut expressions, the notorious self-invocation limitation, and production-ready use cases.

The core idea: behavior wrapped around a join point

A few terms, precisely:

Term Meaning Join point A point in execution where advice can be applied. In Spring AOP, always a method execution. Pointcut An expression selecting which join points match. Advice The code that runs at a matched join point (@Before, @After, @Around, etc.). Aspect A class bundling pointcuts and advice (@Aspect). Weaving Linking aspects into target objects. Spring does this at runtime via proxies. Target The original, un-proxied bean.

The key constraint: Spring AOP only intercepts method calls that go through the proxy. It does not rewrite your bytecode (that’s full AspectJ). It wraps your bean in a proxy object and intercepts calls that arrive from outside through that proxy.

caller --> [ PROXY ] --> advice (around) --> [ TARGET method ]
|
+--> proceed() invokes the real method

Two proxy strategies: JDK dynamic vs CGLIB

When Spring decides a bean needs AOP, it creates a proxy. There are two mechanisms:

JDK dynamic proxies

Used when the bean implements at least one interface (historically Spring’s default in that case). The proxy is a runtime-generated class that implements the same interfaces and delegates to an InvocationHandler.

  • The proxy is type-compatible with the interface, not the concrete class.
  • You must inject the interface, not the implementation, or injection fails with a ClassCastException-style error.

CGLIB proxies

Used when there is no interface, or when forced. CGLIB generates a subclass of your target class at runtime and overrides methods to insert advice.

  • Works on concrete classes.
  • Cannot proxy final classes or final/private/static methods (it can't override them).
  • Requires a non-arg constructor reachable by the subclass (modern Spring uses Objenesis to sidestep this in most cases).

Spring Boot defaults to CGLIB (spring.aop.proxy-target-class=true) so behavior is consistent whether or not your bean has an interface. You can flip to JDK proxies with proxy-target-class=false.

+-------------------+-----------------------------+--------------------------+
| Aspect | JDK Dynamic Proxy | CGLIB Proxy |
+-------------------+-----------------------------+--------------------------+
| Requires interface| Yes | No |
| Proxy is a... | new class impl. interface | subclass of target |
| final classes | N/A | Cannot proxy |
| final/private mtd | N/A | Not intercepted |
| Inject as | interface type | class or interface type |
+-------------------+-----------------------------+--------------------------+

The practical takeaway: with CGLIB, a final method silently won't be advised — no error, just no transaction/log/retry. That alone explains many "annotation does nothing" reports.

Writing your first aspect

Enable AOP (Spring Boot auto-enables it when spring-boot-starter-aop is on the classpath; otherwise add @EnableAspectJAutoProxy). Then declare an aspect bean:

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class LoggingAspect {
private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class); // Reusable named pointcut: all public methods in the service package
@Pointcut("execution(public * com.example.app.service..*(..))")
void serviceMethods() {}
@Around("serviceMethods()")
public Object logExecution(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
String sig = pjp.getSignature().toShortString();
try {
Object result = pjp.proceed(); // invokes the target method
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("{} completed in {} ms", sig, ms);
return result;
} catch (Throwable ex) {
log.warn("{} threw {}", sig, ex.getClass().getSimpleName());
throw ex; // never swallow unless intentional
}
}
}

ProceedingJoinPoint.proceed() is the linchpin: nothing downstream runs unless you call it. Forgetting proceed() in an @Around advice silently disables the entire target method — a brutal bug.

The advice types

Advice When it runs Can change result? Can prevent target? @Before Before the method No No (only by throwing) @After After (finally semantics) No No @AfterReturning After normal return Can read return value No @AfterThrowing After an exception No (can rethrow) No @Around Wraps the call Yes (controls proceed) Yes

Get Marcelo Domingues’s stories in your inbox

Join Medium for free to get updates from this writer.

@Around is the most powerful — it can short-circuit, retry, transform arguments and results — but also the easiest to misuse. Prefer the narrower advice types when they suffice.

@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void onReturn(Object result) {
log.debug("returned: {}", result);
}
@AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
public void onError(Exception ex) {
log.error("failure", ex);
}

Pointcut expressions: the AspectJ mini-language

Spring uses a subset of AspectJ’s pointcut syntax. The most common designators:

  • execution(...) — match by method signature. The workhorse.
  • within(...) — match all join points within certain types.
  • @annotation(...) — match methods carrying a specific annotation.
  • @within(...) — match methods of classes carrying an annotation.
  • args(...) / @args(...) — match by argument types/annotations.
  • bean(name) — match by Spring bean name (Spring-specific).

execution syntax:

execution(modifiers? return-type declaring-type?.method-name(params) throws?)

Examples:

// any return type, any class, method named save, any args
@Pointcut("execution(* *..save(..))")
// public methods returning String in OrderService
@Pointcut("execution(public String com.example.OrderService.*(..))")
// methods taking a Long as the first arg
@Pointcut("execution(* com.example..*(Long, ..))")
// combine: service methods that are NOT in the audit package
@Pointcut("serviceMethods() && !within(com.example.app.service.audit..*)")

Annotation-driven pointcuts (the practical pattern)

For reusable cross-cutting behavior, define a custom annotation and match on it. This is exactly how Spring’s own @Transactional is wired.

import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
String value() default "";
}
@Around("@annotation(timed)")
public Object measure(ProceedingJoinPoint pjp, Timed timed) throws Throwable {
String name = timed.value().isBlank()
? pjp.getSignature().toShortString() : timed.value();
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("metric[{}] = {} ms", name, ms);
}
}

Binding timed as a parameter both filters the pointcut and gives the advice access to the annotation's attributes.

The self-invocation limitation (the #1 gotcha)

Because Spring AOP works through a wrapping proxy, only calls that arrive through the proxy are intercepted. A method calling another method on the same object uses the raw this reference — which is the target, not the proxy. The advice is bypassed.

@Service
public class ReportService {
    @Transactional
public void generateAll() {
// BUG: this.generateOne(...) bypasses the proxy.
// The @Transactional below does NOT start a new transaction.
generateOne(1L);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(Long id) {
// ...
}
}

generateAll calls generateOne via this, so REQUIRES_NEW is ignored. The same trap kills @Async, @Cacheable, @Retryable, and @PreAuthorize when invoked internally.

Fixes

  1. Move the annotated method to a separate bean (cleanest). Inject the other bean and call through it.
  2. Self-inject the proxy and call through it:
@Service
public class ReportService {
    private final ReportService self; // the proxy    public ReportService(@Lazy ReportService self) {
this.self = self; // @Lazy breaks the circular dependency
}
@Transactional
public void generateAll() {
self.generateOne(1L); // goes through the proxy -> advice applies
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(Long id) { ... }
}
  1. Use AopContext.currentProxy() (requires @EnableAspectJAutoProxy(exposeProxy = true)):
((ReportService) AopContext.currentProxy()).generateOne(1L);

Option 1 is almost always the right answer. Options 2 and 3 are escape hatches that signal a design that wants splitting.

Aspect ordering

When multiple aspects match the same join point, ordering matters (e.g. you want logging outside the transaction, or retry outside metrics). Control it with @Order or Ordered:

@Aspect
@Component
@Order(1) // lower value = higher priority = outermost
public class RetryAspect { ... }
@Aspect
@Component
@Order(2)
public class MetricsAspect { ... }

The wrapping forms nested layers: Order(1) wraps Order(2) wraps the target. Note that Spring's transaction advice has a default order of Ordered.LOWEST_PRECEDENCE, so your custom aspects typically run outside the transaction unless you say otherwise.

A real, production-grade retry aspect

Cross-cutting retry is a textbook AOP use case. Here is a self-contained implementation (no Resilience4j dependency):

import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int maxAttempts() default 3;
long backoffMs() default 200;
Class<? extends Throwable>[] on() default { Exception.class };
}
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Aspect
@Component
@Order(0) // outermost: retry wraps everything else
public class RetryAspect {
@Around("@annotation(retry)")
public Object retry(ProceedingJoinPoint pjp, Retry retry) throws Throwable {
int attempts = 0;
Throwable last = null;
while (attempts < retry.maxAttempts()) {
attempts++;
try {
return pjp.proceed();
} catch (Throwable ex) {
if (!isRetryable(ex, retry.on()) || attempts >= retry.maxAttempts()) {
throw ex;
}
last = ex;
Thread.sleep(retry.backoffMs() * attempts); // linear backoff
}
}
throw last; // unreachable, but satisfies the compiler
}
private boolean isRetryable(Throwable ex, Class<? extends Throwable>[] types) {
for (Class<? extends Throwable> t : types) {
if (t.isInstance(ex)) return true;
}
return false;
}
}

Usage:

@Retry(maxAttempts = 4, backoffMs = 250, on = { IOException.class })
public PaymentResult charge(PaymentRequest req) { ... }

Remember: this only fires when charge is called from another bean. Internal self-calls bypass it — the lesson repeats because the bug repeats.

Accessing context inside advice

Advice often needs to inspect the method, its arguments, or its annotations. The JoinPoint API exposes all of it:

@Before("serviceMethods()")
public void audit(org.aspectj.lang.JoinPoint jp) {
String method = jp.getSignature().getName(); // method name
Object target = jp.getTarget(); // the un-proxied bean
Object[] args = jp.getArgs(); // actual arguments
Class<?> type = jp.getSignature().getDeclaringType(); // declaring class
log.debug("calling {}.{} with {} args", type.getSimpleName(), method, args.length);
}

For @Around, ProceedingJoinPoint extends JoinPoint and adds proceed() plus proceed(Object[] newArgs) — the latter lets you rewrite arguments before the target runs, which is how features like input sanitization or masking aspects work:

@Around("@annotation(MaskPii)")
public Object mask(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
if (args.length > 0 && args[0] instanceof String s) {
args[0] = s.replaceAll("(?<=.{2}).(?=.*@)", "*"); // crude email mask
}
return pjp.proceed(args); // proceed with modified args
}

This power is also a warning: an aspect that silently rewrites arguments or results is hard to debug because the behavior lives outside the method. Use argument rewriting sparingly and document it loudly.

A metrics aspect with Micrometer

A second canonical use case is timing. Rather than littering Timer.Sample calls through your services, capture them in one aspect bound to a custom annotation:

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class MetricsAspect {
private final MeterRegistry registry; public MetricsAspect(MeterRegistry registry) {
this.registry = registry;
}
@Around("@annotation(Timed)")
public Object record(ProceedingJoinPoint pjp) throws Throwable {
Timer.Sample sample = Timer.start(registry);
String name = pjp.getSignature().toShortString();
String outcome = "success";
try {
return pjp.proceed();
} catch (Throwable t) {
outcome = "error";
throw t;
} finally {
sample.stop(registry.timer("method.timed",
"method", name, "outcome", outcome));
}
}
}

The same pattern underpins Spring’s own @Timed support in spring-boot-actuator — it's aspects all the way down.

Spring AOP vs full AspectJ

Spring AOP is proxy-based and limited to Spring beans and method execution. AspectJ (load-time or compile-time weaving) modifies bytecode and can intercept constructors, field access, and private/internal calls — including self-invocation — because there is no proxy involved.

Reach for AspectJ only when you genuinely need those capabilities. For 95% of applications, Spring AOP is simpler, has no build-time weaving step, and is plenty. Enable AspectJ weaving via @EnableLoadTimeWeaving and an agent only when the proxy model truly can't express your need.

Common pitfalls and gotchas

  • Self-invocation: internal calls bypass the proxy. The root cause of most “annotation does nothing” issues.
  • final methods/classes with CGLIB: silently not advised; CGLIB can't override them.
  • private methods are never advised: pointcuts apply to proxied (effectively public/protected) methods only.
  • Forgetting proceed() in @Around: the target never runs.
  • Swallowing exceptions in advice: rethrow unless you mean to suppress. Catching and not rethrowing changes method semantics invisibly.
  • Injecting the concrete class under JDK proxies: causes injection failures; inject the interface or force CGLIB.
  • Calling AOP-annotated methods from constructors/@PostConstruct: the proxy may not be fully wired yet.
  • Ordering assumptions: without explicit @Order, the relative order of multiple aspects is undefined.
  • Performance of broad pointcuts: execution(* *(..)) matches everything and adds overhead to every call. Scope pointcuts tightly.

Best practices

  • Prefer custom annotations + @annotation(...) pointcuts over broad execution expressions — they're explicit and self-documenting.
  • Use the narrowest advice type that works; reserve @Around for when you must control proceed().
  • Keep aspects free of business logic. They are infrastructure.
  • Always set @Order when more than one aspect can match.
  • Split classes so annotated methods are called externally, sidestepping self-invocation entirely.
  • For resilience features, prefer battle-tested libraries (Resilience4j) over hand-rolled aspects in production; build your own only for simple, well-understood concerns.

Conclusion

Spring AOP is “just” a proxy wrapping your bean and intercepting external method calls. Every confusing behavior — the dead @Transactional, the ignored @Async, the unhelpful @Cacheable — traces back to that single fact: if the call doesn't go through the proxy, the advice doesn't run. Master the proxy model, choose CGLIB vs JDK proxies deliberately, write tight annotation-driven pointcuts, and design your beans so cross-cutting methods are always invoked from the outside. Do that, and AOP becomes a precise tool instead of an occasional mystery.

Found this useful? Follow devdomain for more under-the-hood Spring deep dives, and comment with the AOP bug that cost you the most time — I bet it was self-invocation.

--

--

Marcelo Domingues
Marcelo Domingues

Written by Marcelo Domingues

🚀 Lead Software Engineer for Siemens | Crafting Code & Words | Empowering Tech Enthusiasts ✨ 📲 LinkedIn:

devdomain
devdomain

Published in devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.