JDK dynamic proxies vs CGLIB, why calling your own method bypasses AOP advice entirely, pointcut expressions, the five advice types, and where @Transactional itself fits into all of this.
Published September 23, 2026
Bean Lifecycle In Detail already revealed where AOP proxies get created (postProcessAfterInitialization). This lesson covers what those proxies actually do.
Spring AOP works by wrapping a target bean in a proxy that intercepts method calls, runs any matching "advice," and delegates to the real method. Which proxy mechanism gets used depends on what the target implements:
@Configuration class proxying (see Component Scanning & Configuration).@Service
class OrderService {
@Transactional
void placeOrder(Order order) { /* ... */ }
void bulkPlaceOrders(List<Order> orders) {
for (Order o : orders) placeOrder(o); // calling 'this.placeOrder()' directly — NOT through the proxy
}
}
When bulkPlaceOrders calls placeOrder(o), that's a plain Java method call on this — it never goes through the Spring-generated proxy at all, because the call originates from inside the same object, not from an external caller holding the proxy reference. Since @Transactional (and every other AOP advice) is implemented entirely via proxy interception, a call that never reaches the proxy gets none of that advice — placeOrder's transactional behavior, logging, security checks, whatever advice would normally apply, simply doesn't run in this self-invocation case. This trips up nearly everyone the first time, and the standard fixes are: move the self-invoked method to a different bean (so the call comes from outside, through that bean's own proxy), or inject the bean's own proxy reference via ApplicationContext/AopContext.currentProxy() and call through that explicitly.
@Pointcut("execution(* com.example.service.*.*(..))") // any method, any return type, in any class in this package
void serviceLayer() {}
@Pointcut("within(com.example.repository..*)") // any method in this package or sub-packages
void repositoryLayer() {}
@Pointcut("@annotation(com.example.Loggable)") // any method annotated with @Loggable, regardless of package
void loggableMethods() {}
execution() matches by method signature pattern (package, class, method name, parameter types). within() matches more coarsely, by type/package location. @annotation() matches by the presence of a specific annotation on the method, regardless of where that method lives — this is exactly the mechanism @Transactional itself relies on: Spring's transaction infrastructure is an AOP aspect whose pointcut is @annotation(Transactional).
@Aspect
@Component
class LoggingAspect {
@Before("serviceLayer()")
void logBefore(JoinPoint jp) { log.info("Entering " + jp.getSignature()); }
@After("serviceLayer()")
void logAfter(JoinPoint jp) { log.info("Exiting " + jp.getSignature()); } // runs regardless of success/failure
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
void logSuccess(Object result) { log.info("Returned: " + result); } // only on successful return
@AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
void logFailure(Exception ex) { log.error("Failed", ex); } // only on exception
@Around("serviceLayer()")
Object logDuration(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed(); // explicitly invokes the actual target method
log.info("Took " + (System.currentTimeMillis() - start) + "ms");
return result;
}
}
@Around is the most powerful and most different from the rest: it receives a ProceedingJoinPoint and must explicitly call .proceed() to invoke the target method at all — which means @Around advice can decide whether the target method runs, modify its arguments before calling proceed(), or replace its return value entirely after. The other four advice types (Before/After/AfterReturning/AfterThrowing) run automatically around a normal method invocation with no ability to prevent it from executing.
Logging, security checks (verifying a caller's permissions before a method runs), caching (short-circuiting method execution entirely if a cached result exists — an @Around advice deciding not to call proceed()), and — worth calling out explicitly — transaction management itself. @Transactional isn't a special language feature; it's an ordinary AOP aspect (an @Around-style advice) whose pointcut matches @annotation(Transactional), wrapping the target method's execution in transaction begin/commit/rollback logic (the full mechanics are in @Transactional Deep Dive) — the exact same proxy-and-self-invocation caveats covered in this lesson apply to it directly.
Q: If a class implements an interface, does Spring always use a JDK dynamic proxy for it?
A: By default, yes, but this is configurable (@EnableAspectJAutoProxy(proxyTargetClass = true) forces CGLIB even for interface-implementing classes) — some teams prefer always-CGLIB for consistency, since JDK dynamic proxies can only proxy methods declared on the implemented interface, which occasionally surprises people when a public method exists on the concrete class but not the interface.
Q: Can @Around advice modify the arguments passed to the target method?
A: Yes — ProceedingJoinPoint.proceed(Object[] args) accepts a replacement argument array, letting @Around advice transform inputs before the target method ever sees them, not just intercept the output.
Q: Does AOP advice apply to private methods? A: No — proxy-based AOP can only intercept calls that go through the proxy's public interface, and a private method can never be called from outside the class in the first place, so it's structurally impossible for external-facing proxy interception to apply to it, self-invocation issue aside.
Q: Why is @annotation() pointcut matching so central to how Spring's own built-in features (like @Transactional) work?
A: It decouples 'which methods get this behavior' from any specific package or naming convention — a team can put @Transactional on any method anywhere in the codebase and it just works, because the pointcut matches the annotation's presence directly rather than requiring methods to live in a specific package structure for a within()-style pointcut to catch them.