How does Spring AOP work, and what are its limits?
Spring builds a proxy around your bean — a JDK dynamic proxy if it has an interface, a generated subclass otherwise — and the advice lives in the proxy, not in your class. Every limit follows: self-invoked, private, final and static methods never cross the proxy, so an annotation on them does nothing and nothing warns you.
The Answer
Say this in the room. 45 seconds.
- Spring AOP is proxy-based. At startup a post-processor replaces your bean with a proxy that wraps it; the advice lives in the proxy.
- Two kinds. JDK dynamic proxy when the bean has an interface — it implements the interface and holds your object inside. CGLIB otherwise — it subclasses your class and overrides its methods. Spring Boot defaults to CGLIB for both.
- So advice runs only on a call that crosses the proxy boundary — an external call from another bean.
- Therefore it cannot see: a self-invoked call, a private method, a final method or class, a static method, or anything called during construction.
- All of those fail silently. Nothing throws, nothing logs, and the code reads as though the annotation worked.
- This is one mechanism, not five.
@Transactional,@Cacheable,@Async,@PreAuthorize,@Retryableand your own aspects all share it — and all share its blind spots.
Understand It
The proxy is a different object from your bean
Spring does not modify your class. It builds a second object that implements the same interface — or extends the same class — and puts your object inside it. Callers get the second object.
You can build the same thing in plain Java. This is a JDK dynamic proxy, exactly the kind Spring creates when your bean has an interface:
RealAuditService target = new RealAuditService();
AuditService proxy = advised(target, "audit");
System.out.println("target class : " + target.getClass().getSimpleName());
System.out.println("proxy class : " + proxy.getClass().getSimpleName());
System.out.println("proxy instanceof AuditService : " + (proxy instanceof AuditService));
System.out.println("proxy instanceof RealAuditService : " + (proxy instanceof RealAuditService));
System.out.println();
System.out.println("recordAll, which loops and calls record itself:");
proxy.recordAll(List.of("login", "logout"));target class : RealAuditService
proxy class : $Proxy0
proxy instanceof AuditService : true
proxy instanceof RealAuditService : false
recordAll, which loops and calls record itself:
audit → recordAll
writing login
writing logout
audit ← recordAllTwo things in that output are the whole entry.
The proxy is not a RealAuditService. It is a generated class called $Proxy0 that implements the interface and nothing else. That is why injecting the concrete class fails once an aspect appears:
@Autowired RealAuditService audit; // the application does not start
The bean in the container is a $Proxy0. It satisfies AuditService and not your class, so there is no candidate of the type you asked for. Injection by type fails with NoSuchBeanDefinitionException; a lookup by name that finds the bean and then checks its type fails with BeanNotOfRequiredTypeException. Inject the interface, or force CGLIB.
The inner calls got no advice. recordAll was called through the proxy, so it produced audit → and audit ←. The two record calls inside it produced neither, because they ran on this — and this is your object, which knows nothing about the proxy wrapped around it.
The other kind of proxy, and what a subclass cannot override
With no interface, Spring generates a subclass with CGLIB and overrides your methods. That changes which limits apply, so it is worth watching a subclass proxy hit them:
ReportService proxy = new AdvisedReportService(new ReportService());
System.out.println("generate — a plain public method:");
proxy.generate("Q3");
System.out.println("archive — final:");
proxy.archive("Q3");
System.out.println("publish — public, but calls a private method:");
proxy.publish("Q3");generate — a plain public method:
audit → generate
generating Q3
audit ← generate
archive — final:
archiving Q3
publish — public, but calls a private method:
audit → publish
checksum Q3
published Q3
audit ← publisharchive ran with no advice at all — no audit →, no audit ←. It is final, so no subclass can override it, so no subclass proxy can wrap it. The compiler enforces this: adding an override to the proxy class does not compile.
publish was advised, but the checksum call inside it was not. Private methods are not inherited, so they cannot be overridden either — and they are always self-invoked by definition.
The pattern is the same in both proxy kinds:
| What | JDK dynamic proxy | CGLIB subclass |
|---|---|---|
| Public interface method | advised | advised |
| Public method not on the interface | invisible | advised |
protected method | invisible | advised |
private method | invisible | invisible |
final method | invisible | invisible |
static method | invisible | invisible |
| Self-invoked call | invisible | invisible |
final class | n/a | cannot proxy at all |
One row in that table needs a caveat, and it is a favourite probe. CGLIB can advise a protected method, so a custom aspect will match one — but @Transactional still will not, because Spring's transaction metadata reader only considers public methods. Two different restrictions, one symptom. "Make it public" and "make it non-final" fix different problems, which is why guessing between them wastes an afternoon.
Spring Boot sets proxyTargetClass=true, so you get CGLIB even when an interface exists. That is why @Autowired on the concrete class usually works in a Boot application and then breaks the day someone adds an interface plus spring.aop.proxy-target-class=false.
CGLIB has one more trap worth knowing: the proxy is allocated without running your constructor, so its own copy of every field is null or 0. Methods delegate to the target and behave correctly; reading a field directly does not. Never read fields off an injected bean — which is the usual reason not to make them accessible in the first place.
Advice wraps, and wrapping nests
Around-advice is a function that receives the call and decides whether, when and how often to proceed. Stack two aspects and you get an onion, not a queue:
AuditService service = advised(advised(new RealAuditService(), "inner"), "outer");
service.record("login"); outer → record
inner → record
writing login
inner ← record
outer ← recordThe outer aspect sees the call first and the return last. In Spring the order is set by @Order on the aspect, and the lowest number is outermost — it runs first on the way in and last on the way out. Get that backwards and your logging aspect reports a duration that excludes the retry aspect it was meant to measure.
This nesting is why around-advice can do things before-advice cannot: swallow the exception, return a cached value without calling through, or call through twice. It is also why an aspect that forgets to proceed() makes the target method silently never run.
Where the proxy does not exist yet
The proxy is created by a BeanPostProcessor after the bean is constructed and initialised. So during construction there is no proxy, and any call you make on this — from the constructor, from @PostConstruct, from an InitializingBean — is a call on a raw, unadvised object.
@Service
public class WarmCache {
@PostConstruct
void preload() {
loadRegions(); // @Cacheable here does nothing: no proxy yet
}
@Cacheable("regions")
public List<Region> loadRegions() { ... }
}
Same class of bug, different cause: not self-invocation this time, but a proxy that does not exist yet. The symptom is identical — the annotation appears to do nothing.
Spring AOP versus AspectJ
Spring AOP is deliberately small: proxies, method execution join points only, Spring beans only. AspectJ is a different technology that rewrites bytecode, either at compile time or at class load, and has none of these limits — it can advise private methods, final methods, constructors, field access and plain objects Spring never created.
The cost is build or agent configuration, and a much larger surface to reason about. In practice: use Spring AOP, and when you hit a limit, redesign rather than reach for AspectJ. Nearly every self-invocation problem is a class doing two jobs, and the fix — a second bean — is the better design anyway.
The @Aspect annotation is AspectJ's, and so is the pointcut syntax. That borrowing is why people assume they are using AspectJ when they are not.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "How does Spring AOP work?" A post-processor replaces the bean with a proxy at startup — a JDK dynamic proxy if the bean has an interface, a CGLIB subclass otherwise. The advice lives in the proxy, which delegates to your untouched object. Every limit of Spring AOP is a limit of proxying.
2. "When does Spring pick CGLIB over a JDK proxy?"
Classically: JDK when the bean implements an interface, CGLIB when it does not. Spring Boot sets proxyTargetClass=true by default, so it is CGLIB either way unless you turn that off.
3. "Why can't a final method be advised?" CGLIB advises by subclassing and overriding. A final method cannot be overridden, so there is nowhere to put the advice. A final class cannot be subclassed at all, so Spring cannot build the proxy.
4. "My @Cacheable method is being called every time. Why?" Almost always self-invocation — another method of the same class calls it directly, so the call never crosses the proxy. Also check that it is public, non-final, and that the caching infrastructure is enabled. All of those fail silently.
5. "Two aspects on one method. Which runs first?"
The one with the lowest @Order is outermost: first in, last out. They nest like an onion rather than queueing, which matters as soon as one of them measures time or catches exceptions.
6. "Why does an annotation on a @PostConstruct-called method do nothing?"
The proxy is created after initialisation, by a post-processor. During @PostConstruct there is no proxy yet, and the call is on the raw bean.
7. "How would you prove an aspect is actually applied?"
Write a test that observes the behaviour — a call count, a transaction count, a cache hit — not one that asserts the annotation is present. AopUtils.isAopProxy(bean) tells you the bean is proxied; only behaviour tells you the call path was advised.
8. "When would you use AspectJ instead?" When you genuinely need what proxies cannot do: private or final methods, constructors, field access, or objects Spring did not create. It is load-time or compile-time weaving, so it costs build configuration. Usually the honest answer is that a redesign is cheaper.
Code traps
Trap A — predict before you run:
@Service
public class ImportService {
@Async
public void importOne(Row row) { ... }
public void importAll(List<Row> rows) {
rows.forEach(this::importOne);
}
}
Answer
Every row is imported synchronously, on the calling thread. this::importOne is a method reference bound to this — the raw bean, not the proxy — so it is self-invocation with a nicer syntax. A method reference hides the this. that would have made the bug visible in a review. The fix is a second bean holding importOne.
Trap B:
@Service
public final class PricingService {
@Transactional
public void reprice(Long id) { ... }
}
Answer
The application fails to start — with Boot's CGLIB default Spring cannot subclass a final class, so it cannot create the proxy. This one is the good case: it is loud. Make the class non-final, or give it an interface and turn off proxyTargetClass. Compare with a final method, which fails silently instead.
Trap C:
@Aspect
@Component
public class TimingAspect {
@Around("@annotation(Timed)")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
pjp.proceed();
return null;
}
}
Answer
Two bugs. It returns null instead of the result of proceed(), so every advised method now returns null — and a method returning int throws NullPointerException on unboxing at the call site, far from this file. It also never records the elapsed time it measured. Around-advice must return what proceed() returned; forgetting is the most common aspect bug there is.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Spring weaves the advice into my class." | It builds a separate proxy object. Your class is untouched. Weaving is AspectJ. |
"@Aspect means I am using AspectJ." | The annotation and pointcut syntax are AspectJ's; the mechanism is Spring's proxies. |
| "AOP works on any method." | Public, non-final, non-static, called from outside. Otherwise it is inert. |
| "It fails fast if it can't apply." | Only for a final class. Final methods, private methods and self-invocation fail silently. |
| "Aspects run in order, one after another." | They nest. The outermost sees the call first and the return last. |
Check Yourself
Q1. Why does injecting the concrete class break once an aspect is added, and why only sometimes?
Answer
With a JDK dynamic proxy the bean is a generated class implementing your interface — it is not an instance of your class, so there is no candidate of the concrete type and the context fails to start. With a CGLIB proxy the bean is a subclass of your class, so it still matches. Boot defaults to CGLIB, which is why the same code works in one project and fails in another.
Q2. A method reference this::doWork passed to a stream — is the annotation on doWork applied?
Answer
No. The reference is bound to this, the raw bean, so every invocation bypasses the proxy. It is self-invocation with syntax that hides it — there is no visible this. for a reviewer to notice.
Q3. Your around-advice measures a duration that is always far shorter than the real call. What would you check first?
Answer
Aspect ordering. If a retry or transaction aspect is outside yours, you are timing one attempt inside their work rather than the whole operation. @Order decides it, and the lowest value is outermost — first in, last out.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Build both kinds of proxy | 10 min |
| Challenge | Find every method the proxy cannot see | 25 min |
| Production | The retry that never retried | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Build both kinds of proxy
One concept, guided. Near-impossible to fail.
- Challenge25 min
Find every method the proxy cannot see
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The retry that never retried
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — Spring AOP
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- spring boot autoconfiguration — not written yet
- spring circular dependencies — not written yet
- spring stereotype annotations — not written yet
Questions that lead here
Why is constructor injection preferred over field injection?
Constructor injection makes the dependency list part of the type, so a bean cannot be built wrong, its fields can be final, and a cycle fails immediately. Field injection wires in a second phase after construction, which is why it hides both a missing dependency and a circular one until something calls the method.
Asked constantlyjunior1–8 yrs9 min readSpring coreHow does @Transactional actually work, and when does it silently do nothing?
Spring wraps your bean in a proxy that opens a transaction before the method and commits after. Everything surprising follows from that: a self-invoked call bypasses the proxy entirely, and a checked exception commits instead of rolling back.
Asked constantlyintermediate2–8 yrs10 min readSpring data
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-27.