Spring · 03

AOP & Proxies

The mechanism behind @Transactional, @Cacheable, @Async and @PreAuthorize: a generated stand-in object that wraps your bean and runs advice around every method call. Learn how the proxy is built, why this.method() skips it, how pointcuts and advice ordering work, and prove it by hand-writing a proxy in plain Java.

13
Chapters
2
Proxy types
5
Advice kinds
2
Runnable demos
01
Chapter One · The Problem

Why AOP Exists

Every non-trivial service method ends up wearing the same three coats: log what happened, measure how long it took, and wrap it in a transaction. Written by hand, those coats are cross-cutting concerns — they cut across every class, belong to none of them, and drown the business rule they surround. After a year you have 200 methods with 200 slightly different copies of the same try/finally.

Before — the business rule is 1 line out of 18
@Service public class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); private final OrderRepository repo; private final PlatformTransactionManager txm; private final MeterRegistry metrics; public Order place(OrderRequest req) { long t0 = System.nanoTime(); log.info("place() start {}", req); TransactionStatus tx = txm.getTransaction(new DefaultTransactionDefinition()); try { Order saved = repo.save(Order.from(req)); // <-- the only business line txm.commit(tx); return saved; } catch (RuntimeException e) { txm.rollback(tx); log.error("place() failed", e); throw e; } finally { metrics.timer("order.place").record(System.nanoTime() - t0, TimeUnit.NANOSECONDS); } } }
After — the same behaviour, declared
@Service public class OrderService { private final OrderRepository repo; public OrderService(OrderRepository repo) { this.repo = repo; } @Transactional @Timed("order.place") public Order place(OrderRequest req) { return repo.save(Order.from(req)); } }

Where did the other 17 lines go? They did not disappear — Spring moved them out of the class and around it. The OrderService bean you inject is not an OrderService at all but a generated stand-in (a proxy) that runs the transaction and timer code and only then calls your method. That substitution is the whole of Spring AOP.

Cross-cutting concerns before and after AOP
BEFORE: concerns copy-pasted into every methodplace()logtx beginBUSINESStx committimercancel()logtx beginBUSINESStx committimerrefund()logtx beginBUSINESStx committimer3 methods x 4 copies of plumbing = 12 places to get wrongAFTER: one proxy wraps a clean targetproxy (generated at startup)TimingInterceptorTransactionInterceptorOrderService (target)only the business ruleconcerns live once, applied by rule (pointcut / annotation)
Analogy: A security desk at a company lobby. Employees (your business methods) just do their jobs; every visitor (a call) passes the desk, which checks the badge, logs the entry and may turn them away — and the employee never wrote a line of that logic. The desk is the proxy; the checks are the advice.
The vocabulary you will need in 60 seconds
  • Aspect — a module holding cross-cutting logic (a class annotated @Aspect, or a built-in one like the transaction interceptor).
  • Join point — a point in execution you could hook. In Spring AOP it is always a method execution on a Spring bean.
  • Pointcut — a predicate selecting join points (execution(* com.acme.orders..*Service.*(..))).
  • Advice — the code that runs at the join point: before, after, around, …
  • Advisor — Spring’s pairing of one pointcut with one advice; the unit the auto-proxy creator actually works with.
  • Target — the real object; proxy — the object that wraps it and gets injected instead.
02
Chapter Two · Mental Model

How a Proxied Call Actually Runs

Spring creates your bean normally (constructor, injection, @PostConstruct)
▶
The raw object exists — but nobody has seen it yet. It is the target.
A BeanPostProcessor (AnnotationAwareAspectJAutoProxyCreator) runs after initialization
▶
It asks every Advisor in the context: "does your pointcut match any method of this class?"
At least one advisor matches
▶
The creator builds a proxy (JDK or CGLIB) holding the target + the matching advisors, and returns the proxy instead of the target to the container.
The container stores the proxy under the bean name and injects it everywhere
▶
Callers hold a proxy reference. The raw target is reachable only through the proxy.
A caller invokes orderService.place(req)
▶
The call lands on the proxy. It finds the interceptor chain for place and runs it as a nested "onion".
The innermost step of the chain is reflection on the target
▶
Your method runs, the return value (or exception) unwinds back through each interceptor in reverse order.
Runtime sequence of one proxied call (Timing = outer advisor, Tx = inner)
ClientProxyTimingTxTarget1 place(req)2 chain[0].invoke(mi)3 mi.proceed()4 mi.proceed() -> reflect<- place() runs5 return Order6 commit7 record timer8 Order

Two properties of this model explain almost every AOP surprise you will meet. First, the advice only runs when a call crosses the proxy boundary — code inside the target calling its own methods never touches the proxy. Second, the proxy has to be created and handed out by the container; an object you build with new has no proxy and so no advice.

One sentence to remember
  • Spring AOP = the bean you inject is a different object than the one you wrote. Everything else (final methods, self-calls, @Transactional on private methods, "why is my aspect not firing") is a consequence of that sentence.
03
Chapter Three · Scope

Spring AOP vs AspectJ

AOP is a programming idea; Spring AOP and AspectJ are two implementations with very different mechanics. Spring AOP is deliberately small: it uses runtime proxies, supports only method-execution join points on Spring beans, and borrows AspectJ’s annotation style and pointcut language (@Aspect, @Around, execution(...)) without using its weaver. Full AspectJ rewrites bytecode.

AspectSpring AOP (proxy-based)AspectJ (weaving)
MechanismJDK dynamic proxy or CGLIB subclass created at runtime by a BeanPostProcessorBytecode modified at compile time (ajc), post-compile, or load time (Java agent)
Join pointsPublic/protected method execution on Spring-managed beans (calls from outside the object)Method call & execution, constructors, field get/set, static init, exception handlers
Objects advisedOnly beans created by the containerAny class, including new-ed objects and domain entities
Self-invocationNot advised (the call never leaves this)Advised — advice is woven into the method body itself
Setup costAdd spring-boot-starter-aop; nothing elseBuild plugin or -javaagent; @EnableLoadTimeWeaving
PerformanceOne reflective/proxy hop per advised call; negligible for service-layer workNear zero runtime overhead; cost moves to build/startup
Typical useTransactions, caching, security, logging, metrics on servicesFine-grained domain logic, field-level auditing, advising non-Spring objects

In a Spring Boot 3 application, @Aspect classes need the AspectJ library on the classpath (for the annotations and the pointcut parser) — that is what spring-boot-starter-aop brings in — but there is still no weaving. Boot’s AopAutoConfiguration turns on @EnableAspectJAutoProxy with proxyTargetClass=true by default (spring.aop.proxy-target-class=true), meaning class-based CGLIB proxies are the default for everything, including @Transactional beans that implement interfaces.

pom.xml — the only dependency you add
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
⚠ Production gotchas
  • Aspect never fires, no error. Usual causes: starter-aop missing (so @Aspect is just an inert class), the aspect lacks @Component (it must be a bean), or the target is not a Spring bean. Check with AopUtils.isAopProxy(bean).
  • Pointcut using call(...), get(...), set(...), initialization(...). Spring AOP throws UnsupportedPointcutPrimitiveException at startup — those primitives exist only in AspectJ.
  • Expecting field-level interception (e.g. audit every setter of an entity). Entities are not Spring beans; use JPA entity listeners / Hibernate Envers or real AspectJ weaving.
04
Chapter Four · Proxy Mechanics

JDK Dynamic Proxies vs CGLIB

A proxy must be usable wherever the target was. There are exactly two ways to achieve that on the JVM, and Spring uses both. The choice is made in DefaultAopProxyFactory.createAopProxy(): if the target class has interfaces and proxyTargetClass is false it builds a JdkDynamicAopProxy; otherwise a ObjenesisCglibAopProxy (Spring’s repackaged CGLIB, org.springframework.cglib).

Two ways to stand in for the target
JDK dynamic proxy«interface» OrderServiceOrderServiceImpl$Proxy123implements the interface onlycast to OrderServiceImpl -> ClassCastExceptionCGLIB proxy (Boot default)OrderService (class)OrderService$$SpringCGLIB$$0extendssubclass overrides every non-final, non-private methodno interface required; constructor NOT called (Objenesis)
JDK dynamic proxyCGLIB proxy
Built withjava.lang.reflect.Proxy + InvocationHandlerByte-code generated subclass of the target class
RequiresAt least one interface on the targetNon-final class; visible (non-private) constructor is not needed thanks to Objenesis
Proxy typeImplements the interfaces only — not a subtype of the classIs-a target class — injectable by class or interface
Can interceptInterface methodspublic, protected and package-visible non-final methods
Cannot interceptMethods not on an interface; staticfinal / private / static methods (they are skipped silently)
Fails whenYou inject the concrete class: BeanNotOfRequiredTypeExceptionClass is final: AopConfigException: Could not generate CGLIB subclass
Boot 3 defaultOnly if you set spring.aop.proxy-target-class=falseYes (proxyTargetClass=true)
Forcing the choice
// Boot: application.properties // spring.aop.proxy-target-class=false -> JDK proxies where interfaces exist // Plain Spring Framework: @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = false) class AopConfig { } // Programmatic (no container) - the same machinery: ProxyFactory pf = new ProxyFactory(new OrderServiceImpl()); pf.setProxyTargetClass(true); // CGLIB even though an interface exists pf.addAdvice((MethodInterceptor) inv -> { System.out.println("before " + inv.getMethod().getName()); return inv.proceed(); }); OrderService proxy = (OrderService) pf.getProxy();

Why Objenesis matters. Old CGLIB proxies called the target’s constructor to create the subclass instance, which ran constructor side effects twice. Since Spring 4 the proxy instance is allocated without running any constructor. Consequence: the proxy object’s own fields are null/default. Spring routes every non-final call to the real target, so you never notice — unless a method is final. A final method runs on the proxy shell, sees null fields, and throws a baffling NullPointerException on an apparently non-null injected dependency.

The classic final-method trap
@Service public class PaymentService { private final GatewayClient gateway; // injected into the TARGET only public PaymentService(GatewayClient gateway) { this.gateway = gateway; } @Transactional public void charge(String id) { gateway.charge(id); } // fine: proxy forwards to target @Transactional public final void refund(String id) { // final: NOT overridden by CGLIB gateway.refund(id); // runs on the proxy shell -> gateway == null -> NPE; no tx either } }
⚠ Production gotchas
  • BeanNotOfRequiredTypeException: Bean named 'orderService' is expected to be of type 'com.acme.OrderServiceImpl' but was actually of type 'jdk.proxy2.$Proxy45' — you are on JDK proxies and injected the implementation class. Inject the interface, or leave Boot’s CGLIB default alone.
  • Kotlin / Java records and final classes. Kotlin classes are final by default — use the kotlin-spring (allopen) compiler plugin. Java records are final and cannot be CGLIB-proxied; don’t put @Transactional on a record.
  • @Transactional on a private method does nothing (no error). On Spring 6+ with class-based proxies protected and package-private methods are honoured; private never is.
  • Lombok-generated or hand-written equals/hashCode touching fields invoked on the proxy — CGLIB forwards them, but final overrides won’t forward. Keep them non-final on proxied beans.
  • Check what you have: AopUtils.isAopProxy(b), AopUtils.isCglibProxy(b), AopUtils.isJdkDynamicProxy(b), AopProxyUtils.ultimateTargetClass(b).
05
Chapter Five · Selecting Join Points

Pointcut Expressions

A pointcut expression decides which method executions receive advice. It is evaluated by AspectJ’s pointcut parser against each method of each candidate bean, once, during auto-proxy creation — not on every call (though some designators such as args, this, @annotation with binding may need a small runtime check). The result for each method is cached in the advisor chain.

The anatomy of execution
// execution( modifiers? return-type declaring-type? method-name ( params ) throws? ) // // execution(public * com.acme.orders.service.*Service.*(..)) // ^^^^^^ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^ ^^^^ // public any return any class named *Service any method name, any args // // execution(* com.acme..*Repository.find*(String, ..)) // '..' in a package = sub-packages // execution(Order com.acme..*.save(Order)) // exact return and parameter type
DesignatorSelectsExample
executionMethod signature (the workhorse)execution(* com.acme.billing..*(..))
withinAll methods of types in a package/classwithin(com.acme.web..*)
@annotationMethod carrying an annotation@annotation(com.acme.Audited)
@withinMethods of classes carrying an annotation@within(org.springframework.stereotype.Service)
@targetRuntime target class carries annotation@target(com.acme.Tenanted)
args / @argsRuntime argument types / argument annotationsargs(String, ..)
this / targetthis = the proxy is an instance of; target = the target is an instance oftarget(com.acme.Auditable)
beanBy bean name (Spring-specific)bean(*Service) && !bean(legacyService)

Combine with &&, ||, ! and give pieces names with @Pointcut so expressions are reusable and readable.

A shared pointcut library
@Aspect public class Pointcuts { @Pointcut("within(com.acme..service..*)") public void inServiceLayer() { } @Pointcut("execution(public * *(..))") public void publicMethod() { } @Pointcut("@annotation(com.acme.audit.Audited)") public void audited() { } @Pointcut("inServiceLayer() && publicMethod() && !audited()") public void plainServiceCall() { } } @Aspect @Component class TimingAspect { @Around("com.acme.aop.Pointcuts.plainServiceCall()") public Object time(ProceedingJoinPoint pjp) throws Throwable { /* ... */ return pjp.proceed(); } }
Binding arguments, annotations and the target into advice
@Before("execution(* com.acme..OrderService.cancel(..)) && args(orderId, reason)") public void beforeCancel(Long orderId, String reason) { log.info("cancel {} because {}", orderId, reason); // names must match the advice parameters } @Around("@annotation(audited)") // bind the annotation instance itself public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable { log.info("action={} method={}", audited.action(), pjp.getSignature().toShortString()); return pjp.proceed(); } @AfterReturning(pointcut = "execution(* com.acme..*Service.find*(..))", returning = "result") public void afterFind(JoinPoint jp, Object result) { /* result is the method's return value */ }
PointcutMatches OrderService.place(Order)?Why
execution(* com.acme.orders.OrderService.*(..))YesExact type, any method
execution(* com.acme.OrderService.*(..))NoThe class lives in com.acme.orders; without .. the package must match exactly. com.acme..OrderService would match
within(com.acme..*)YesAny type under com.acme
execution(* *Service.place(..)) && args(String)NoArgument is an Order, not a String
@annotation(org.springframework.transaction.annotation.Transactional)Only if the method has itClass-level @Transactional needs @within
⚠ Production gotchas
  • Pointcut too broad: execution(* *(..)). You advise every bean including framework infrastructure; startup slows and you get Bean ... is not eligible for getting processed by all BeanPostProcessors noise. Always scope by package.
  • Pointcut matches your aspect itself → StackOverflowError or recursion. Exclude with !within(com.acme.aop..*).
  • Typo in a type name does not fail. execution(* com.acme.OrdreService.*(..)) is a valid expression that simply matches nothing. Add a test that asserts the proxy exists (AopUtils.isAopProxy).
  • args() and parameter-name binding requires the -parameters compiler flag (Boot’s parent POM sets it) or a matching argNames; otherwise IllegalArgumentException: error at ::0 formal unbound in pointcut.
06
Chapter Six · Advice

Advice Types & Ordering

Spring supports five advice types. Four are conveniences built on top of the fifth, @Around, which is the only one that controls whether and how the target runs.

AdviceRunsCan change result?Can stop the call?Typical use
@BeforeBefore the methodNoOnly by throwingValidation, MDC setup, access check
@AfterReturningAfter normal returnNo (sees it via returning)NoPublish an event with the result
@AfterThrowingAfter an exceptionNo (sees it via throwing)No — exception continuesAlerting, error metrics
@After (finally)Always, after return/throwNoNoCleanup, MDC.clear()
@AroundWraps the callYes (return anything)Yes (don’t call proceed())Timing, tx, cache, retry, circuit breaker
All five in one aspect
@Aspect @Component public class DemoAspect { @Before("execution(* com.acme..OrderService.place(..))") public void before(JoinPoint jp) { log.info("1 before {}", jp.getSignature().getName()); } @Around("execution(* com.acme..OrderService.place(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { log.info("0 around-enter"); try { return pjp.proceed(); } // MUST be called, MUST return its value finally { log.info("4 around-exit"); } } @AfterReturning(pointcut = "execution(* com.acme..OrderService.place(..))", returning = "o") public void ok(Object o) { log.info("2 afterReturning {}", o); } @AfterThrowing(pointcut = "execution(* com.acme..OrderService.place(..))", throwing = "ex") public void bad(Exception ex) { log.warn("2 afterThrowing {}", ex.toString()); } @After("execution(* com.acme..OrderService.place(..))") public void always() { log.info("3 after (finally)"); } }
Log output on success (Spring 5.2.7+ ordering within one aspect)
0 around-enter 1 before place ... target method runs ... 2 afterReturning Order[id=7] 3 after (finally) 4 around-exit

Ordering across aspects. Advice from different aspects forms nested layers. The aspect with the highest precedence (smallest @Order value) is the outermost: its "before" part runs first and its "after" part runs last. Between advice of the same type inside one aspect the order is undefined — split them into separate aspects if you need determinism. Built-in advisors take part too: @EnableTransactionManagement(order=...) defaults to Ordered.LOWEST_PRECEDENCE, so a custom aspect with a smaller order wraps the transaction (and runs even if commit fails), while a larger order would run inside it.

Onion model: smaller @Order = outer layer = first in, last out
@Order(1) SecurityAspect@Order(2) TimingAspectTransactional advisor (LOWEST_PRECEDENCE)target.place()enter order: 1 -> 2 -> txexit order: tx -> 2 -> 1
⚠ Production gotchas
  • @Around that forgets proceed() silently skips the business method — and returns null. Review every around advice for the single proceed() path.
  • @Around swallows the return value: declaring void advice return type makes the caller get null (or AopInvocationException: Null return value from advice does not match primitive return type for primitives). Return Object.
  • Catching Exception and not rethrowing hides failures from the transaction interceptor too, which then commits. Rethrow or let it propagate.
  • Declaring throws Exception instead of Throwable around proceed() is a compile error: proceed() throws Throwable.
  • Calling proceed() twice (retry aspect) is allowed — each call re-runs the rest of the chain, which is exactly how a retry aspect re-opens a new transaction per attempt if it is ordered outside the tx advisor.
  • Aspect order surprise with @Transactional: an aspect that writes to the DB at LOWEST_PRECEDENCE would run inside the transaction; give audit/outbox aspects an explicit @Order based on whether they must share the transaction.
07
Chapter Seven · Applied

A Complete Production Aspect

Here is a complete, production-shaped aspect: an @Audited annotation that records who did what, to which argument, with what outcome and how long it took. It uses a custom annotation as the pointcut (explicit and greppable beats package patterns), binds the annotation, handles success and failure and never changes the method’s result.

Audited.java
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Audited { String action(); boolean logArgs() default false; // avoid leaking PII by default }
AuditAspect.java
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) // outside @Transactional: audits even rolled-back attempts public class AuditAspect { private static final Logger audit = LoggerFactory.getLogger("AUDIT"); private final Clock clock; private final CurrentUser currentUser; public AuditAspect(Clock clock, CurrentUser currentUser) { this.clock = clock; this.currentUser = currentUser; } @Around("@annotation(audited)") public Object record(ProceedingJoinPoint pjp, Audited audited) throws Throwable { Instant start = clock.instant(); String outcome = "OK"; try { return pjp.proceed(); } catch (Throwable t) { outcome = "FAILED:" + t.getClass().getSimpleName(); throw t; // never swallow } finally { long ms = Duration.between(start, clock.instant()).toMillis(); String args = audited.logArgs() ? Arrays.toString(pjp.getArgs()) : "[hidden]"; audit.info("user={} action={} method={} args={} outcome={} ms={}", currentUser.name(), audited.action(), pjp.getSignature().toShortString(), args, outcome, ms); } } }
Usage
@Service public class RefundService { @Audited(action = "REFUND_ISSUED", logArgs = true) @Transactional public Refund issue(long orderId, BigDecimal amount) { /* ... */ return refund; } }
Resulting log line
user=alice action=REFUND_ISSUED method=RefundService.issue(..) args=[42, 19.99] outcome=OK ms=18 user=bob action=REFUND_ISSUED method=RefundService.issue(..) args=[43, 5000] outcome=FAILED:InsufficientFundsException ms=7
Unit-testing the aspect with a real proxy (no Spring context needed)
class AuditAspectTest { @Test void auditedMethodsAreProxied() { AspectJProxyFactory factory = new AspectJProxyFactory(new RefundService()); factory.addAspect(new AuditAspect(Clock.systemUTC(), () -> "test-user")); RefundService proxy = factory.getProxy(); assertThat(AopUtils.isAopProxy(proxy)).isTrue(); proxy.issue(1L, BigDecimal.TEN); // advice runs; assert on captured log output } }
⚠ Production gotchas
  • Logging all arguments by default. Arguments contain passwords, card numbers, tokens. Make argument logging opt-in (as above) and prefer ids over objects.
  • Doing slow or failing I/O in the finally block (writing to a DB synchronously) can mask the real exception or add latency to every call. Push to a bounded async queue, or publish an event after commit.
  • Injecting a prototype/request-scoped bean into the aspect (the aspect is a singleton). The CurrentUser above must be a proxy that resolves per-request, or read SecurityContextHolder directly.
  • Aspect failing at startup with Error creating bean with name ... Unsatisfied dependency because the aspect depends on a bean that itself needs advising: aspects are created very early; keep their dependencies minimal and use ObjectProvider for the rest.
08
Chapter Eight · The Big Trap

Self-Invocation & Other Reasons Advice Disappears

The single most common AOP bug, and a standard interview question: a method that calls another method of the same class bypasses the proxy. The call is this.other(), and this is the target, not the proxy. Advice on other() — transaction, cache, async, security — silently does not run.

Self-invocation: the inner call stays inside the target
ControllerProxyadvice chain hereTarget: OrderServiceplaceTwice() @Transactionalplace() @Transactional(REQUIRES_NEW)12 advice runs3 this.place() (direct call!)The dashed call never crosses the proxy,so place() gets NO new transaction,NO cache lookup, NO @Async hop,NO security check.
The bug
@Service public class OrderService { @Transactional public void placeTwice(Order a, Order b) { place(a); // this.place(a) -> REQUIRES_NEW is IGNORED, runs in placeTwice's tx place(b); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void place(Order o) { repo.save(o); } }
FixHowTrade-off
Move to another bean (recommended)Extract place() into OrderPlacer and inject it; the call crosses a proxyCleanest, testable; a small refactor
Inject self@Lazy @Autowired OrderService self; then self.place(a)Works, but circular-looking and surprising to readers
AopContext.currentProxy()@EnableAspectJAutoProxy(exposeProxy=true); ((OrderService) AopContext.currentProxy()).place(a)Couples business code to Spring AOP; must be on the same thread
TransactionTemplate / programmaticUse the template inside the methodExplicit, no proxy needed for transactions only
AspectJ weaving mode@EnableTransactionManagement(mode=AdviceMode.ASPECTJ) + weaverHandles self-calls, but heavier build/runtime setup
Fix 1 — a second bean
@Service @RequiredArgsConstructor public class OrderService { private final OrderPlacer placer; // a different bean => a different proxy @Transactional public void placeTwice(Order a, Order b) { placer.place(a); // crosses the proxy: new tx per call placer.place(b); } } @Service class OrderPlacer { @Transactional(propagation = Propagation.REQUIRES_NEW) public void place(Order o) { /* ... */ } }
What AopContext says when exposeProxy is off
java.lang.IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.
⚠ Other "my advice does not run" causes (same family)
  • Object created with new (new OrderService()) — never went through the container, no proxy.
  • Calling from a @PostConstruct method of the same bean: the proxy does not exist yet (post-init processing runs after), so @Transactional/@Cacheable calls there are not advised. Use ApplicationReadyEvent or SmartInitializingSingleton.
  • Bean created too early by a BeanPostProcessor/BeanFactoryPostProcessor dependency chain: INFO log Bean 'x' of type [...] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Make BPPs static @Bean methods with minimal dependencies.
  • Advice on a private/final method, or on a method of a final class (see Chapter 4).
09
Chapter Nine · Spring Itself

What @Transactional, @Cacheable & @Async Really Are

Several of the best-known Spring annotations carry no logic at all — they are markers read by a built-in advisor. Understanding that makes their limits predictable: they inherit every property of proxies from the previous chapters.

AnnotationEnabled byAdvisor / interceptor that does the workWhat it does around your method
@Transactional@EnableTransactionManagement (auto in Boot)BeanFactoryTransactionAttributeSourceAdvisor → TransactionInterceptorGets tx from PlatformTransactionManager, binds to thread, commits/rolls back
@Cacheable / @CacheEvict@EnableCachingBeanFactoryCacheOperationSourceAdvisor → CacheInterceptorLooks up key; skips the method on hit; stores result on miss
@Async@EnableAsyncAsyncAnnotationAdvisor (via AsyncAnnotationBeanPostProcessor) → AnnotationAsyncExecutionInterceptorSubmits the call to a TaskExecutor, returns immediately
@Validated (method validation)MethodValidationPostProcessor (auto in Boot)MethodValidationInterceptorValidates args/return; throws ConstraintViolationException
@PreAuthorize / @PostAuthorize@EnableMethodSecurityAuthorizationManagerBeforeMethodInterceptor / ...AfterMethodInterceptorEvaluates a SpEL rule against the current Authentication
@Scheduled@EnableSchedulingNot a proxy — ScheduledAnnotationBeanPostProcessor registers tasksIncluded to show the contrast: a plain BPP, no AOP

All proxy-based ones are registered as Advisors (a pointcut that looks for the annotation + an interceptor), and one shared auto-proxy creator — InfrastructureAdvisorAutoProxyCreator or, once @EnableAspectJAutoProxy is active, AnnotationAwareAspectJAutoProxyCreator (bean name org.springframework.aop.config.internalAutoProxyCreator) — wraps each eligible bean once, with all matching advisors in a single proxy. That is why one bean can be @Transactional, @Cacheable and carry your aspect without being proxied three times.

Where the proxy is born inside bean creation
createBean()instantiate + populateinitializeBean()@PostConstruct, init-methodBPP after-initAbstractAutoProxyCreatorwrapIfNecessary()findEligibleAdvisors()createProxy()ProxyFactory.getProxy()singleton cachestores the PROXYfor each singleton bean, in orderfindEligibleAdvisors(): collect Advisor beans (annotation advisors + @Aspect-derived advisors),keep those whose Pointcut.getClassFilter() and getMethodMatcher() match ANY method of the class,sort by Ordered/@Order. Empty list -> bean returned unwrapped (no proxy at all).Non-empty -> one proxy carrying the whole advisor list.
Making @Async and @Transactional compose (order matters)
@Service public class ReportService { @Async // outer: hops to another thread FIRST @Transactional // inner: tx begins on the worker thread public CompletableFuture<Report> build(long id) { return CompletableFuture.completedFuture(generate(id)); } } // The caller's transaction does NOT follow @Async: a new thread has no thread-bound tx.
⚠ Production gotchas
  • @Async with no @EnableAsync (or on a self-invoked method) runs synchronously with no warning.
  • @Cacheable on a method that is called from the same class never caches — self-invocation again. The symptom is a DB hit on every call despite a warm cache.
  • @Transactional + @Async + returning an entity to the caller: lazy collections fail later with LazyInitializationException because the worker’s session closed. Return DTOs.
  • @PreAuthorize on a controller with @EnableMethodSecurity missing: annotation ignored, endpoint open. Boot does not enable method security for you.
  • Stacked annotations and proxy order: unless you set order on the enabling annotations, relative order of cache vs transaction advisors is not something to rely on — cache outside the transaction is usually what you want (a hit should not open a tx).
10
Chapter Ten · Build It Yourself

Hand-Write the Proxy

To prove to yourself that there is no magic, build the entire mechanism in plain Java — no Spring, compiled with javac. Part A is a JDK dynamic proxy with a chain of advices (the same onion as Spring’s ReflectiveMethodInvocation: an index into a list, and proceed() advances it). It also demonstrates self-invocation. Part B is a hand-written subclass proxy, which is exactly what CGLIB generates.

MiniAop.java — interceptor chain + JDK proxy
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.List; public class MiniAop { interface Advice { Object invoke(Invocation inv) throws Throwable; } static class Invocation { final Object target; final Method method; final Object[] args; private final List<Advice> chain; private int index = -1; Invocation(Object target, Method method, Object[] args, List<Advice> chain) { this.target = target; this.method = method; this.args = args; this.chain = chain; } Object proceed() throws Throwable { if (++index == chain.size()) return method.invoke(target, args); return chain.get(index).invoke(this); } } static class AdvisedHandler implements InvocationHandler { private final Object target; private final List<Advice> chain; AdvisedHandler(Object target, List<Advice> chain) { this.target = target; this.chain = chain; } public Object invoke(Object proxy, Method m, Object[] args) throws Throwable { try { return new Invocation(target, m, args, chain).proceed(); } catch (java.lang.reflect.InvocationTargetException e) { throw e.getCause(); } } } @SuppressWarnings("unchecked") static <T> T proxy(Class<T> iface, T target, Advice... advices) { return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, new AdvisedHandler(target, List.of(advices))); } interface OrderService { String place(String item); String placeTwice(String item); } static class OrderServiceImpl implements OrderService { public String place(String item) { return "order:" + item; } public String placeTwice(String item) { return place(item) + "," + place(item); // this.place(): bypasses the proxy } } public static void main(String[] args) { List<String> log = new ArrayList<>(); Advice timing = inv -> { log.add("timing >> " + inv.method.getName()); try { return inv.proceed(); } finally { log.add("timing << " + inv.method.getName()); } }; Advice tx = inv -> { log.add("tx BEGIN"); try { Object r = inv.proceed(); log.add("tx COMMIT"); return r; } catch (Throwable t) { log.add("tx ROLLBACK"); throw t; } }; OrderService svc = proxy(OrderService.class, new OrderServiceImpl(), timing, tx); System.out.println("proxy class : " + svc.getClass().getName().replaceAll("[0-9]+$", "N")); System.out.println("is Impl? : " + (svc instanceof OrderServiceImpl)); System.out.println("result : " + svc.place("book")); log.forEach(l -> System.out.println(" " + l)); log.clear(); System.out.println("--- self-invocation ---"); System.out.println("result : " + svc.placeTwice("pen")); log.forEach(l -> System.out.println(" " + l)); System.out.println("(only ONE tx pair: the two inner place() calls never touched the proxy)"); } }
javac MiniAop.java && java MiniAop
proxy class : $ProxyN is Impl? : false result : order:book timing >> place tx BEGIN tx COMMIT timing << place --- self-invocation --- result : order:pen,order:pen timing >> placeTwice tx BEGIN tx COMMIT timing << placeTwice (only ONE tx pair: the two inner place() calls never touched the proxy)

Read the output: the proxy class is not the implementation (is Impl? false), advice ran in registration order on the way in and reverse order on the way out, and placeTwice got one transaction even though it calls place twice — the inner calls were this.place() inside the target.

CglibStyle.java — a hand-written subclass proxy
public class CglibStyle { // A subclass-based proxy, written by hand the way CGLIB generates it. static class AccountService { public String withdraw(int amt) { return "withdrew " + amt; } public final String audit() { return "audit"; } // final: cannot be overridden public String batch() { return withdraw(1) + " & " + withdraw(2); } } static class AccountService$$Proxy extends AccountService { private final AccountService target = new AccountService(); @Override public String withdraw(int amt) { System.out.println(" [advice] before withdraw(" + amt + ")"); String r = target.withdraw(amt); System.out.println(" [advice] after withdraw"); return r; } @Override public String batch() { System.out.println(" [advice] before batch()"); return target.batch(); // runs inside target: this.withdraw() is the TARGET's } } public static void main(String[] a) { AccountService s = new AccountService$$Proxy(); System.out.println("withdraw -> " + s.withdraw(5)); System.out.println("batch -> " + s.batch()); System.out.println("audit -> " + s.audit() + " (final: cannot be overridden, so never intercepted)"); } }
javac CglibStyle.java && java CglibStyle
[advice] before withdraw(5) [advice] after withdraw withdraw -> withdrew 5 [advice] before batch() batch -> withdrew 1 & withdrew 2 audit -> audit (final: cannot be overridden, so never intercepted)

Note batch(): advice ran once for batch but not for the two withdraw calls inside it — same bug, different proxy flavour. And audit(), being final, was not overridden, so nothing could intercept it.

Try this
  • Add a third advice to MiniAop that retries up to 3 times: it calls inv.proceed() in a loop — but Invocation.index only moves forward. How would you reset it? (Spring clones the invocation per retry: MethodInvocation.invocableClone().)
11
Chapter Eleven · Diagnosis

Debugging Proxies & Advice

When AOP misbehaves, do not guess — ask the container what it built.

Interrogating a bean
@Component class ProxyInspector implements ApplicationRunner { private final OrderService svc; ProxyInspector(OrderService svc) { this.svc = svc; } @Override public void run(ApplicationArguments args) { System.out.println("class = " + svc.getClass()); // OrderService$$SpringCGLIB$$0 System.out.println("isAopProxy = " + AopUtils.isAopProxy(svc)); System.out.println("isCglib = " + AopUtils.isCglibProxy(svc)); System.out.println("target class = " + AopProxyUtils.ultimateTargetClass(svc)); if (svc instanceof Advised advised) { for (Advisor a : advised.getAdvisors()) System.out.println("advisor = " + a.getAdvice().getClass().getSimpleName()); } } }
Typical output
class = class com.acme.orders.OrderService$$SpringCGLIB$$0 isAopProxy = true isCglib = true target class = class com.acme.orders.OrderService advisor = TransactionInterceptor advisor = CacheInterceptor advisor = AspectJAroundAdvice
SymptomLikely causeHow to confirm
Aspect never runsNot a bean / no starter-aop / pointcut typoisAopProxy(bean) false; list Advised.getAdvisors()
Works in test, not in prod flowSelf-invocation or new-ed objectStack trace shows OrderService.place called directly from OrderService.placeTwice without CglibAopProxy$DynamicAdvisedInterceptor between
Only some methods advisedPrivate/final/static, or pointcut narrower than assumedBreakpoint in your advice; check method modifiers
NullPointerException on injected field in a proxied beanFinal method run on CGLIB shellStack frame in $$SpringCGLIB$$ class; make method non-final
Startup slowdownOverly broad pointcut forcing proxies on every beanlogging.level.org.springframework.aop=DEBUG, check count of proxies
ClassCastException ... $ProxyJDK proxy cast to classInject the interface or use proxyTargetClass=true
⚠ Production gotchas
  • Reading stack traces: a deep trace with ReflectiveMethodInvocation.proceed / CglibAopProxy$CglibMethodInvocation frames is normal — those are the chain. Look for the first frame below them to find your code.
  • Performance myths: a proxied call adds microseconds; do not remove advice for speed unless a profiler (async-profiler / JFR) shows ReflectiveMethodInvocation hot — usually the advice content (logging, serialisation of args) is the real cost.
  • Unit tests that new-up the class will not exercise advice. Use AspectJProxyFactory (as shown) or @SpringBootTest slices when the behaviour under test is the advice.
12
Chapter Twelve · Interviews

Interview Angles

  • What is a proxy in Spring and why does the container need it?
    A generated stand-in that has the target’s type, holds the target and an interceptor chain, and is injected instead of the raw bean. It lets cross-cutting code run around method calls without editing the class.
  • JDK vs CGLIB proxy — difference and Boot’s default?
    JDK = java.lang.reflect.Proxy, implements interfaces only. CGLIB = generated subclass; needs a non-final class. Boot defaults to CGLIB (spring.aop.proxy-target-class=true) so injecting by class works. Trap: final methods are not intercepted.
  • Why does @Transactional not work when I call another method in the same class?
    The call is this.method() on the target, bypassing the proxy where the TransactionInterceptor lives. Fix: separate bean, self-injection, AopContext.currentProxy() or AspectJ mode.
  • Where exactly does Spring create the proxy?
    In a BeanPostProcessor, AbstractAutoProxyCreator.postProcessAfterInitialization → wrapIfNecessary. For circular references it can create an early proxy in getEarlyBeanReference.
  • Advice types and which one can change the return value?
    Before, AfterReturning, AfterThrowing, After, Around. Only @Around (via ProceedingJoinPoint.proceed()) can replace the result, skip the target, or retry.
  • How do you order two aspects?
    @Order or Ordered. Smaller value = higher precedence = outermost: first on the way in, last on the way out.
  • Spring AOP vs AspectJ?
    Spring AOP: runtime proxies, method execution on beans only. AspectJ: weaving; any join point, any object, works on self-calls. Trap: Spring uses AspectJ’s annotation/pointcut syntax, not its weaver.
  • Can you advise a private method, a constructor or a field access?
    Not with Spring AOP. Private/final/static methods are skipped; constructors and field access are not join points. Use AspectJ weaving.
  • A bean works, but AopUtils.isAopProxy returns false. Why?
    No advisor’s pointcut matched any of its methods, so the auto-proxy creator returned it unwrapped — or it was created before the creator was ready.
  • How would you prove a method is called through the proxy?
    Log AopUtils.isAopProxy(this) from inside (false — this is the target), or inspect the stack for DynamicAdvisedInterceptor / JdkDynamicAopProxy.invoke.
13
Chapter Thirteen · Review

Summary & Cheat Sheet

QuestionAnswer in one line
What is injected?A proxy; the target is hidden behind it
Who makes it?AbstractAutoProxyCreator (a BeanPostProcessor), after init
JDK vs CGLIB?Interface-implementing vs subclass; Boot = CGLIB
What can be advised?Non-final public/protected methods of Spring beans, called from outside
What cannot?Self-calls, private/final/static, new-ed objects, constructors, fields
Advice order?Smaller @Order = outer; within an aspect use separate aspects for determinism
Annotation magic?@Transactional/@Cacheable/@Async = markers + built-in interceptors in the same chain
∑ AOP & Proxies — what to remember
  • Spring AOP is proxy-based: the injected bean is a generated wrapper around the real one.
  • Advice runs only for calls that cross the proxy. this.x() never does.
  • CGLIB subclass (Boot default) cannot override final/private/static methods — those run un-advised, final ones on a field-less shell.
  • @Around is the primitive; always call proceed(), return its value, rethrow exceptions.
  • Pointcuts are matched once at proxy-creation time; scope them by package or custom annotation.
  • @Transactional, @Cacheable, @Async, @PreAuthorize are all advisors in the same chain — their gotchas are proxy gotchas.
  • Diagnose with AopUtils, Advised.getAdvisors() and the stack trace.
Related pages