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 methodTwo 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
finalclasses orfinal/private/staticmethods (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
- Move the annotated method to a separate bean (cleanest). Inject the other bean and call through it.
- 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) { ... }
}
- 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.
finalmethods/classes with CGLIB: silently not advised; CGLIB can't override them.privatemethods 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 broadexecutionexpressions — they're explicit and self-documenting. - Use the narrowest advice type that works; reserve
@Aroundfor when you must controlproceed(). - Keep aspects free of business logic. They are infrastructure.
- Always set
@Orderwhen 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.







