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.
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.
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.
- 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.
How a Proxied Call Actually Runs
@PostConstruct)BeanPostProcessor (AnnotationAwareAspectJAutoProxyCreator) runs after initializationorderService.place(req)place and runs it as a nested "onion".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.
- Spring AOP = the bean you inject is a different object than the one you wrote. Everything else (final methods, self-calls,
@Transactionalon private methods, "why is my aspect not firing") is a consequence of that sentence.
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.
| Aspect | Spring AOP (proxy-based) | AspectJ (weaving) |
|---|---|---|
| Mechanism | JDK dynamic proxy or CGLIB subclass created at runtime by a BeanPostProcessor | Bytecode modified at compile time (ajc), post-compile, or load time (Java agent) |
| Join points | Public/protected method execution on Spring-managed beans (calls from outside the object) | Method call & execution, constructors, field get/set, static init, exception handlers |
| Objects advised | Only beans created by the container | Any class, including new-ed objects and domain entities |
| Self-invocation | Not advised (the call never leaves this) | Advised — advice is woven into the method body itself |
| Setup cost | Add spring-boot-starter-aop; nothing else | Build plugin or -javaagent; @EnableLoadTimeWeaving |
| Performance | One reflective/proxy hop per advised call; negligible for service-layer work | Near zero runtime overhead; cost moves to build/startup |
| Typical use | Transactions, caching, security, logging, metrics on services | Fine-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.
- Aspect never fires, no error. Usual causes: starter-aop missing (so
@Aspectis just an inert class), the aspect lacks@Component(it must be a bean), or the target is not a Spring bean. Check withAopUtils.isAopProxy(bean). - Pointcut using
call(...),get(...),set(...),initialization(...). Spring AOP throwsUnsupportedPointcutPrimitiveExceptionat 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.
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).
| JDK dynamic proxy | CGLIB proxy | |
|---|---|---|
| Built with | java.lang.reflect.Proxy + InvocationHandler | Byte-code generated subclass of the target class |
| Requires | At least one interface on the target | Non-final class; visible (non-private) constructor is not needed thanks to Objenesis |
| Proxy type | Implements the interfaces only — not a subtype of the class | Is-a target class — injectable by class or interface |
| Can intercept | Interface methods | public, protected and package-visible non-final methods |
| Cannot intercept | Methods not on an interface; static | final / private / static methods (they are skipped silently) |
| Fails when | You inject the concrete class: BeanNotOfRequiredTypeException | Class is final: AopConfigException: Could not generate CGLIB subclass |
| Boot 3 default | Only if you set spring.aop.proxy-target-class=false | Yes (proxyTargetClass=true) |
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.
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
finalclasses. Kotlin classes are final by default — use thekotlin-spring(allopen) compiler plugin. Java records are final and cannot be CGLIB-proxied; don’t put@Transactionalon a record. @Transactionalon 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/hashCodetouching fields invoked on the proxy — CGLIB forwards them, butfinaloverrides 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).
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.
execution| Designator | Selects | Example |
|---|---|---|
execution | Method signature (the workhorse) | execution(* com.acme.billing..*(..)) |
within | All methods of types in a package/class | within(com.acme.web..*) |
@annotation | Method carrying an annotation | @annotation(com.acme.Audited) |
@within | Methods of classes carrying an annotation | @within(org.springframework.stereotype.Service) |
@target | Runtime target class carries annotation | @target(com.acme.Tenanted) |
args / @args | Runtime argument types / argument annotations | args(String, ..) |
this / target | this = the proxy is an instance of; target = the target is an instance of | target(com.acme.Auditable) |
bean | By bean name (Spring-specific) | bean(*Service) && !bean(legacyService) |
Combine with &&, ||, ! and give pieces names with @Pointcut so expressions are reusable and readable.
| Pointcut | Matches OrderService.place(Order)? | Why |
|---|---|---|
execution(* com.acme.orders.OrderService.*(..)) | Yes | Exact type, any method |
execution(* com.acme.OrderService.*(..)) | No | The class lives in com.acme.orders; without .. the package must match exactly. com.acme..OrderService would match |
within(com.acme..*) | Yes | Any type under com.acme |
execution(* *Service.place(..)) && args(String) | No | Argument is an Order, not a String |
@annotation(org.springframework.transaction.annotation.Transactional) | Only if the method has it | Class-level @Transactional needs @within |
- Pointcut too broad:
execution(* *(..)). You advise every bean including framework infrastructure; startup slows and you getBean ... is not eligible for getting processed by all BeanPostProcessorsnoise. Always scope by package. - Pointcut matches your aspect itself →
StackOverflowErroror 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-parameterscompiler flag (Boot’s parent POM sets it) or a matchingargNames; otherwiseIllegalArgumentException: error at ::0 formal unbound in pointcut.
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.
| Advice | Runs | Can change result? | Can stop the call? | Typical use |
|---|---|---|---|---|
@Before | Before the method | No | Only by throwing | Validation, MDC setup, access check |
@AfterReturning | After normal return | No (sees it via returning) | No | Publish an event with the result |
@AfterThrowing | After an exception | No (sees it via throwing) | No — exception continues | Alerting, error metrics |
@After (finally) | Always, after return/throw | No | No | Cleanup, MDC.clear() |
@Around | Wraps the call | Yes (return anything) | Yes (don’t call proceed()) | Timing, tx, cache, retry, circuit breaker |
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.
- @Around that forgets
proceed()silently skips the business method — and returns null. Review every around advice for the singleproceed()path. - @Around swallows the return value: declaring
voidadvice return type makes the caller getnull(orAopInvocationException: Null return value from advice does not match primitive return typefor primitives). ReturnObject. - Catching
Exceptionand not rethrowing hides failures from the transaction interceptor too, which then commits. Rethrow or let it propagate. - Declaring
throws Exceptioninstead ofThrowablearoundproceed()is a compile error:proceed()throwsThrowable. - 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 atLOWEST_PRECEDENCEwould run inside the transaction; give audit/outbox aspects an explicit@Orderbased on whether they must share the transaction.
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.
- 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
CurrentUserabove must be a proxy that resolves per-request, or readSecurityContextHolderdirectly. - Aspect failing at startup with
Error creating bean with name ... Unsatisfied dependencybecause the aspect depends on a bean that itself needs advising: aspects are created very early; keep their dependencies minimal and useObjectProviderfor the rest.
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.
| Fix | How | Trade-off |
|---|---|---|
| Move to another bean (recommended) | Extract place() into OrderPlacer and inject it; the call crosses a proxy | Cleanest, 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 / programmatic | Use the template inside the method | Explicit, no proxy needed for transactions only |
| AspectJ weaving mode | @EnableTransactionManagement(mode=AdviceMode.ASPECTJ) + weaver | Handles self-calls, but heavier build/runtime setup |
- Object created with
new(new OrderService()) — never went through the container, no proxy. - Calling from a
@PostConstructmethod of the same bean: the proxy does not exist yet (post-init processing runs after), so@Transactional/@Cacheablecalls there are not advised. UseApplicationReadyEventorSmartInitializingSingleton. - Bean created too early by a
BeanPostProcessor/BeanFactoryPostProcessordependency chain: INFO logBean 'x' of type [...] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Make BPPsstatic @Beanmethods with minimal dependencies. - Advice on a private/final method, or on a method of a
finalclass (see Chapter 4).
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.
| Annotation | Enabled by | Advisor / interceptor that does the work | What it does around your method |
|---|---|---|---|
@Transactional | @EnableTransactionManagement (auto in Boot) | BeanFactoryTransactionAttributeSourceAdvisor → TransactionInterceptor | Gets tx from PlatformTransactionManager, binds to thread, commits/rolls back |
@Cacheable / @CacheEvict | @EnableCaching | BeanFactoryCacheOperationSourceAdvisor → CacheInterceptor | Looks up key; skips the method on hit; stores result on miss |
@Async | @EnableAsync | AsyncAnnotationAdvisor (via AsyncAnnotationBeanPostProcessor) → AnnotationAsyncExecutionInterceptor | Submits the call to a TaskExecutor, returns immediately |
@Validated (method validation) | MethodValidationPostProcessor (auto in Boot) | MethodValidationInterceptor | Validates args/return; throws ConstraintViolationException |
@PreAuthorize / @PostAuthorize | @EnableMethodSecurity | AuthorizationManagerBeforeMethodInterceptor / ...AfterMethodInterceptor | Evaluates a SpEL rule against the current Authentication |
@Scheduled | @EnableScheduling | Not a proxy — ScheduledAnnotationBeanPostProcessor registers tasks | Included 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.
@Asyncwith no@EnableAsync(or on a self-invoked method) runs synchronously with no warning.@Cacheableon 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 withLazyInitializationExceptionbecause the worker’s session closed. Return DTOs.@PreAuthorizeon a controller with@EnableMethodSecuritymissing: annotation ignored, endpoint open. Boot does not enable method security for you.- Stacked annotations and proxy order: unless you set
orderon 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).
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.
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.
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.
- Add a third advice to
MiniAopthat retries up to 3 times: it callsinv.proceed()in a loop — butInvocation.indexonly moves forward. How would you reset it? (Spring clones the invocation per retry:MethodInvocation.invocableClone().)
Debugging Proxies & Advice
When AOP misbehaves, do not guess — ask the container what it built.
| Symptom | Likely cause | How to confirm |
|---|---|---|
| Aspect never runs | Not a bean / no starter-aop / pointcut typo | isAopProxy(bean) false; list Advised.getAdvisors() |
| Works in test, not in prod flow | Self-invocation or new-ed object | Stack trace shows OrderService.place called directly from OrderService.placeTwice without CglibAopProxy$DynamicAdvisedInterceptor between |
| Only some methods advised | Private/final/static, or pointcut narrower than assumed | Breakpoint in your advice; check method modifiers |
NullPointerException on injected field in a proxied bean | Final method run on CGLIB shell | Stack frame in $$SpringCGLIB$$ class; make method non-final |
| Startup slowdown | Overly broad pointcut forcing proxies on every bean | logging.level.org.springframework.aop=DEBUG, check count of proxies |
ClassCastException ... $Proxy | JDK proxy cast to class | Inject the interface or use proxyTargetClass=true |
- Reading stack traces: a deep trace with
ReflectiveMethodInvocation.proceed/CglibAopProxy$CglibMethodInvocationframes 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
ReflectiveMethodInvocationhot — 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@SpringBootTestslices when the behaviour under test is the advice.
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
@Transactionalnot work when I call another method in the same class?
The call isthis.method()on the target, bypassing the proxy where theTransactionInterceptorlives. Fix: separate bean, self-injection,AopContext.currentProxy()or AspectJ mode. - Where exactly does Spring create the proxy?
In aBeanPostProcessor,AbstractAutoProxyCreator.postProcessAfterInitialization→wrapIfNecessary. For circular references it can create an early proxy ingetEarlyBeanReference. - Advice types and which one can change the return value?
Before, AfterReturning, AfterThrowing, After, Around. Only@Around(viaProceedingJoinPoint.proceed()) can replace the result, skip the target, or retry. - How do you order two aspects?
@OrderorOrdered. 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.isAopProxyreturns 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?
LogAopUtils.isAopProxy(this)from inside (false —thisis the target), or inspect the stack forDynamicAdvisedInterceptor/JdkDynamicAopProxy.invoke.
Summary & Cheat Sheet
| Question | Answer 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 |
- 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/staticmethods — those run un-advised, final ones on a field-less shell. @Aroundis the primitive; always callproceed(), return its value, rethrow exceptions.- Pointcuts are matched once at proxy-creation time; scope them by package or custom annotation.
@Transactional,@Cacheable,@Async,@PreAuthorizeare all advisors in the same chain — their gotchas are proxy gotchas.- Diagnose with
AopUtils,Advised.getAdvisors()and the stack trace.
- 02 Bean Scopes & Lifecycle — BeanPostProcessors, the hook that creates proxies.
- 08 Transactions — the most important advisor in detail (propagation, rollback rules).
- 13 Caching & Resilience —
@Cacheableand retry/circuit breakers as interceptors. - 09 Spring Security — method security is the same mechanism.