AOP in Principle: Cross-Cutting Concerns and Proxies
AOP in one sentence: take the code that "every method has to do on the side", pull it out of every method, write it once, and let the container quietly paste it back at runtime. What gets pulled out is usually logging, transactions, authorization or rate limiting; what pastes it back is the proxy object — a stand-in with your class's name and shape that secretly does a bit more work.
what does a dynamic proxy actually proxy? Answer: nothing at all. Think of putting a case on a phone. The case has holes for every button and the charging port, feels identical, and pressing power still switches off the phone — but the case adds a drop-resistant buffer on the side. The phone itself never changed; what changed is which device gets handed to you: the container never hands you the bare phone, only the cased one. Your business code (the phone) does not lose a single line — the extra capabilities live on the case.
an advice chain is an onion, or a sequence of security gates. One call passes inward layer by layer: gate 1 checks your badge (authorization), gate 2 stamps the clock (logging), gate 3 issues a work permit (begin transaction); only at the very core does the actual work happen (the target method). Coming back out is reversed — the innermost gate exits first: return the permit and commit the transaction, then finish the clock stamp with the duration. So "first in, last out" is the master key to every advice ordering question.

After this article you should be able to answer three questions:
- What exactly is a "cross-cutting concern", and why does it not belong inside a business method?
- Which real-world action do aspect / pointcut / join point / advice / weaving each correspond to?
- Why is Spring AOP merely "method-level proxying", and what are its three failure scenarios?
Cut to a code review. The ask is ordinary: every Service method should log its inputs, outputs and duration; key write operations need a transaction; admin endpoints need a permission check. Three sentences to describe, but in code it looks like this:
@Servicepublic class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); public Order createOrder(Long userId, OrderDTO dto) { long start = System.currentTimeMillis(); log.info("createOrder in: userId={}, dto={}", userId, dto); // ↓↓↓ the only lines that actually matter ↓↓↓ Order order = new Order(userId, dto); orderRepository.save(order); // ↑↑↑ the only lines that actually matter ↑↑↑ log.info("createOrder out: orderId={}, took={}ms", order.getId(), System.currentTimeMillis() - start); return order; } public void cancelOrder(Long orderId) { long start = System.currentTimeMillis(); log.info("cancelOrder in: orderId={}", orderId); Order order = orderRepository.findById(orderId).orElseThrow(); order.cancel(); orderRepository.save(order); log.info("cancelOrder out: took={}ms", System.currentTimeMillis() - start); }}- Every method opens by grabbing a timestamp and logging its inputs
- Every method closes by logging its output and duration
- The few middle lines are the method's actual job; everything else is copy-paste to serve logging
Ten Services with twelve methods each is 120 copies. Change the log format once and you edit 120 places — that is exactly what makes cross-cutting logic painful: it has almost nothing to do with the business, yet it changes just as often.
to tell whether code is a "cross-cutting concern", one signal suffices — does it appear in many methods with near-identical content? Logging, transactions, authorization, rate limiting, caching and monitoring all fit.
Set "keep copy-pasting" against "extract one aspect" and the payoff, plus the price, are obvious at a glance:

So how do 120 copies actually get "moved out" of the business methods? This six-frame animation walks the whole collapsing process — watch frame ⑤ in particular: nothing in the source is rewritten, a shell is simply put on at runtime:

Sort that pile of code by "direction" and the problem becomes clear:

- Core concerns (vertical): Controller → Service → Repository answers "what is this business flow", running up and down the call chain
- Cross-cutting concerns (horizontal): logging, transactions, authorization... they belong to no single business, yet must cut "across" nearly every method
The typical cross-cutting concerns and the cost of skipping them:
| Cross-cutting concern | What it does | Cost of skipping |
|---|---|---|
| Logging | Records inputs, outputs, duration, errors | Production incidents become untraceable |
| Transactions | All-or-nothing over a group of operations | Data inconsistency |
| Authorization | Decides if the current user may act | Privilege escalation |
| Rate limiting | Caps requests per unit time | Overwhelmed by traffic spikes |
| Caching | Reuses results, avoids recompute | Poor performance, heavy DB load |
| Monitoring | Metrics, tracing, instrumentation | A system with no observability |
In one line: core concerns decide what business to do; cross-cutting concerns decide what else must happen along the way. The former naturally lives inside methods; the latter naturally wants to be "pulled out" — and AOP exists to do the pulling.
Many find AOP hard because the vocabulary scares them off first. Hold on to one thread: pointcuts select, advice executes, an aspect packages both, and a proxy makes it real.
| Term | In one sentence | In Spring |
|---|---|---|
| Aspect | The packaged cross-cutting logic | A class annotated @Aspect |
| Join point | A point in execution where logic may be inserted | Each interceptable method invocation |
| Pointcut | An expression selecting which join points to intercept | @Pointcut("execution(...)") |
| Advice | The code that actually runs at a join point | @Before / @After / @Around... |
| Target | The original object being proxied | Your UserServiceImpl |
| Proxy | Target plus advice, standing in for it | Objects built by JDK Proxy / CGLIB |
| Weaving | The act of applying aspects to targets | The step that creates the proxy |
String them into one sentence: an aspect uses a pointcut to select methods from the join points, attaches advice to them, and at runtime a proxy wraps the target to perform the weaving. Hold that chain and the terms stop being scattered nouns.
"Weaving" sounds mystical; it is simply the act of inserting advice into a target method's execution flow. It can happen at three moments:
| Moment | Who does it | Traits | Example |
|---|---|---|---|
| Compile time | Advice is compiled into bytecode | No runtime proxy, best performance | AspectJ compile-time weaving |
| Load time (LTW) | Bytecode is rewritten as classes load | Needs an agent / aop.xml | AspectJ load-time weaving |
| Runtime | A proxy object is generated while running | Zero onboarding cost, non-invasive | Spring AOP dynamic proxies |
Spring AOP takes the third path: it generates a proxy at runtime. That also fixes its boundaries — a proxy is essentially a look-alike stand-in and can only intercept calls that go through it.
The fastest way to understand Spring AOP is to hand-write the "dumbest" proxy. Suppose there is a user service interface:
public interface UserService { User findById(Long id); void save(User user);}Write a static proxy that forces logging in:
public class UserServiceLogProxy implements UserService { private final UserService target; // the real target object private static final Logger log = LoggerFactory.getLogger(UserServiceLogProxy.class); public UserServiceLogProxy(UserService target) { this.target = target; } @Override public User findById(Long id) { log.info("findById start, id={}", id); User result = target.findById(id); // forward to the real target log.info("findById end, result={}", result); return result; } @Override public void save(User user) { log.info("save start, user={}", user); target.save(user); log.info("save end"); }}UserServiceLogProxyimplements the same interface asUserServiceImpl, so from outside it "looks identical"- Every method follows the same pattern: log before → call the same method on
target→ log after - The real work still happens in
target; the proxy just "wraps" it, but it successfully moves logging out of the business code
Trap: the fatal flaw of static proxies is not that they are hard to write, but that you can never finish writing them. N methods on the interface means N proxy methods; add a method and every proxy must change. That is one aspect — add transactions and authorization and proxy classes explode combinatorially.
The key takeaway is that the caller actually holds the proxy, and the proxy quietly meddles before and after forwarding. Spring AOP simply swaps "hand-written proxy" for "runtime-generated proxy". The animation shows exactly this call chain:

Interviewers love to ask: "Does AOP replace OOP?" The standard answer: it does not replace, it complements.
- OOP uses inheritance and composition to express "is-a" and "has-a" — vertical structural relations
- AOP expresses "where else something must happen" — horizontal behavioral relations
- OOP slices the business into classes; AOP gathers the cross-cutting logic back into one place
Another analogy: OOP drew the company as a department tree; AOP installs one shared stamping machine for "the process every department must go through". The structure (OOP) is not overturned — it just gains a cross-department way to express things.
AOP complements OOP. It addresses precisely the gap in OOP — some logic genuinely belongs to "behavior shared by many classes" and fits badly inside any single one.
Once you grasp "proxy", the boundaries follow naturally. It is not omnipotent magic; it is method-level proxying:
| Capability | Supported | Notes |
|---|---|---|
| Method invocation interception | Yes | The only join point type in Spring AOP |
| Field access interception | No | Needs AspectJ compile-time weaving |
| Constructor interception | No | Spring AOP cannot do it |
| Beans inside the container | Yes | Only container-managed objects get a proxy |
Objects you new yourself | No | Not in the container, so never enhanced |
| Self-invocation inside a method | No | Bypasses the proxy; advice never runs |
Three boundaries to remember:
- Method level only: field reads and constructor calls are out of reach — that is AspectJ's compile-time territory
- Container beans only: an object created with
newhas no proxy and thus no advice - Self-invocation fails:
this.method()goes straight to the original object and bypasses the proxy — the most common cause of "my aspect is not firing"
The demo below visualizes how the proxy inserts advice before and after a call. Switch between JDK / CGLIB / self-invocation to see exactly when the advice chain works and when it breaks:
Focus on the "self-invocation" mode: a this.xxx() call inside the target never passes through the proxy, so not a single piece of advice fires.
Run the four experiments in order, thirty seconds each. The first is the most striking — the same method with no aspect versus with one woven in produces completely different call stacks:
You already understood the hand-written static proxy above; now see how the advice chain runs when the container generates the proxy for you. Switch to "self-invocation" and not one piece of advice prints — live evidence for the boundary in Section 7:
Now the motivation question: why do we need Spring at all? Hand-writing twelve steps (managing object lifecycles and wiring yourself by hand) versus letting the container do it — the cost option lays out both the code you save and the complexity you buy:
One layer deeper: who actually puts the case on the phone, and when? The auto-proxy creator — a BeanPostProcessor (a hook the container inserts around the creation of every bean) registered in the container that asks, at each bean's after-init stage, "do you match any pointcut?", and builds the proxy right there if you do:
Terms and shells are settled; the real hurdle left is that "the advice chain goes in, then out". This experiment hangs all five advice types on one method. Start on normal and copy the log order down — it contradicts most people's first intuition: the first half of @Around runs before @Before:
Logs alone still blur, because a log is one-dimensional while an advice chain is nested. Spread it into a single-step run: eight lines on the left, live variables and call stack on the right. Press "next" and watch step ④ — the target method executes exactly once, and the other seven steps all spin around it:
orderService.create(dto); // (1) the call hits the proxy, entering the interceptor chain[@Around] long start = System.nanoTime(); // (2) the around part BEFORE proceed - actually first[@Before] log.info("enter"); // (3) before advice[target] orderRepository.save(order); // (4) the only line doing real business work[@AfterReturning] counter.add(result); // (5) runs only on a normal return[@After] MDC.remove("op"); // (6) finally semantics: success or failure[@Around] proceed() returns, log the cost // (7) the around part AFTER proceed - second to lastreturn order; // (8) the onion is unwound; the caller finally gets a value| orderService | OrderServiceImpl$$SpringCGLIB$$0 |
| thread | main |
AopDemo.mainproxy.createRe-watch the onion animation against that trace and the gate order sticks for good:

Those eight steps are one aspect. Real code stacks two or three, and who enters first gets fuzzy — so let's play a round: click an annotation on the left, its true trigger condition on the right. Wrong pairs explain themselves on the spot.
Two of the nastiest paths each deserve their own run. First, an @Around that never calls proceed():
Second, which advice types fire when the target throws — @AfterReturning stays completely silent, which is why a lot of "failure metrics are always zero" dashboards exist:
From here you can type the commands yourself instead of clicking. This console talks to the same container running in your browser, and every answer is computed by the kernel — boot first, beans to see which beans got a shell, then the lab lines one by one:
Five switches decide whether "my aspect is not firing". Flicking them beats memorising a checklist. This sandbox deliberately defines only combinations that differ decisively: once the failure cause is established (say the object is not in the container at all), stacking another problem on top produces no new behaviour, so any unlisted mix collapses onto the nearest cell's verdict.
[log] before save ...# authorization -> logging -> tx -> target -> commit -> durationreturn: okVerdict: the only fully effective combination
Suggested usage: park all four switches on the most correct cell first (the top output) as your baseline, then change exactly one switch at a time and see which step disappears. You will derive the "three requirements for proxying" yourself: the call must go through the proxy, the method must be rewritable, the object must live in the container.
The nastiest thing about AOP is that most failures are silent. Read the table as symptom → cause → self-rescue.
One debugging law first: when an aspect seems dead, your first move is not editing the pointcut — it is printing getClass().getName(). If it says com.example.UserServiceImpl instead of ...$$EnhancerBySpringCGLIB$$... or $Proxy..., no proxy exists and everything else you check is wasted effort.
| Error text (searchable fragment) | Real cause | 30-second self-rescue | Where to dig deeper |
|---|---|---|---|
| No exception, no log, the aspect simply never runs (most common) | ① plain Spring project missing @EnableAspectJAutoProxy; ② the bean is not in the container (hand-newed); ③ the method is not public | Print getClass().getName() to confirm a proxy exists; verify the target class carries @Service/@Component and sits in the scan path; finally make the method public | Section 7 here + article 14 |
java.lang.NoClassDefFoundError: org/aspectj/lang/annotation/Aspect | You used the @Aspect annotation without the AspectJ runtime dependency | Add spring-boot-starter-aop (it brings aspectjweaver too) | Article 13 |
BeanCreationException: Failed to instantiate [com.example.TimingAspect] + Caused by: java.lang.IllegalArgumentException: error The near term's relationship is malformed; a valid WildcardType is required | The pointcut expression is malformed, so the aspect bean fails at creation (thrown by the AspectJ expression parser); usually a wrong package name, a missing .., or a type that does not exist | Check each segment of execution(...) — return type, package, parameters — and first prove the pipeline works with a coarse within(com.example..*) | Article 13 |
A @Transactional method threw an exception yet the data was committed | By default only RuntimeException and Error trigger rollback; checked exceptions do not | Use @Transactional(rollbackFor = Exception.class), or wrap explicitly in your aspect | Article 31 |
Cannot proxy target class because CGLIB-Proxies are disabled, or IllegalArgumentException: Cannot subclass final class | The target class is final, so CGLIB cannot generate a subclass | Drop final, or let the class implement an interface so the JDK proxy path applies | Article 12 |
| Advice lost on intra-class calls; no proxy frame appears in the debugger stack | this.method() never goes through the proxy object | Extract to another bean, inject your own proxy, or ((MyService) AopContext.currentProxy()).method() with exposeProxy = true | Section 7 here + article 15 |
org.aspectj.weaver.reflect.ReflectionWorld$ReflectionWorldException: warning can't determine annotations of missing type on class path | The pointcut is so broad that AspectJ's reflection world touches a type absent from the compile classpath | Narrow it with within(com.example..*), or add the missing dependency to the classpath | Article 13 |
| The aspect works in tests but not in production (or the reverse) | The two environments use different proxies: one has an interface (JDK), the other forces CGLIB | Align spring.aop.proxy-target-class; inject by interface, never by implementation | Section 6 of article 12 |
spring.aop.proxy-target-class=true (the Boot default) routes every bean through CGLIB. The upside is that injecting by implementation class no longer explodes; the price is that final classes need special handling.
That Cannot subclass final class row deserves a real practice run — it is one of the few AOP failures that blow up at startup with a short stack, so the culprit frame is usually easy to point at. Do not peek: click the frame you think is responsible.
You added a stopwatch @Aspect, touched no business code, and the application now refuses to start.
Write a "naked" Service first, then add one aspect, and prove the aspect fires by counting log lines. Fully runnable code:
package com.example.aop;// 1) business interface and impl: not a single log statement insidepublic interface OrderService { String create(String sku);}@Servicepublic class OrderServiceImpl implements OrderService { @Override public String create(String sku) { return "order-" + sku; // only its own job }}package com.example.aop;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.springframework.stereotype.Component;@Aspect // tell the container: this is an aspect@Component // make it a bean — without this the aspect is never foundpublic class TimingAspect { @Pointcut("execution(* com.example.aop.OrderService.*(..))") public void orderApi() {} // pointcut: the filter over join points @Around("orderApi()") public Object timeIt(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); System.out.println("[around] in -> " + pjp.getSignature().getName()); Object result = pjp.proceed(); // travel deeper into the onion, eventually reaching the target System.out.println("[around] out -> " + pjp.getSignature().getName() + " result=" + result + " cost=" + (System.nanoTime() - start) / 1000 + "us"); return result; }}package com.example.aop;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class AopDemo { public static void main(String[] args) { try (ConfigurableApplicationContext ctx = SpringApplication.run(AopDemo.class, args)) { OrderService svc = ctx.getBean(OrderService.class); System.out.println("[class] " + svc.getClass().getName()); System.out.println("[call ] " + svc.create("A1001")); } }}Expected log (the four key lines):
[class] com.sun.proxy.$Proxy78 # or ...OrderServiceImpl$$SpringCGLIB$$0[around] in -> create[around] out -> create result=order-A1001 cost=xxxus[call ] order-A1001If [class] prints a name you never wrote, that is proof the container handed you a stand-in; the paired in/out lines prove the advice chain really wraps the target method in the middle.
Goal: add no new aspect, change only the call path, and make those two [around] lines disappear.
Hint: add public String createTwice(String sku) { return create(sku) + "/" + create(sku); } to OrderServiceImpl, then call svc.createTwice("A1001") from main.
You will observe: createTwice is intercepted (one pair of [around]), but the two inner create(...) calls print no [around] at all, while the result is still order-A1001/order-A1001. That is hard evidence of self-invocation failure — paste these logs in your notes; it beats reciting the conclusion ten times.
Build a "three-in-one method aspect" for every public method under com.example..service..*: ① log arguments and duration, ② record exceptions and rethrow them unchanged, ③ count invocations per method in a ConcurrentHashMap and expose a read-only endpoint returning those counts.
Acceptance checklist:
- [ ] One
@Aspectclass does all three jobs, and the business code contains no log / try-catch / counter at all - [ ]
@AfterThrowingreceives the exception object and the exception still propagates to the caller (never swallow it) - [ ] You deliberately create one self-invocation, explain why the counter under-counts, and give the fix
- [ ] You write the pointcut twice, once with
withinand once withexecution, and note the readability difference - [ ] You can explain why turning these counts into a production metric belongs to Micrometer, not to the aspect
can you explain what a dynamic proxy actually proxies using the phone case, ending with "the phone never changed; the device handed to you did"?
can you assign a everyday role to each of aspect, pointcut, join point, advice and weaving (rulebook / filter condition / every doorway / the concrete action / the casing operation)?
what are Spring AOP's three failure scenarios? (self-invocation; non-public / final / static; the object is not in the container)
can you recite the advice-chain ordering? (first in, last out — the innermost advice exits first)
when an aspect does not fire, what is your very first move? (print getClass().getName() to confirm a proxy exists)
pointcut picks the doors, advice sets the action, the aspect packages it, the proxy delivers it; a self-call uses no door, so nothing rings.
AOP solves "cross-cutting concerns copied into every method". Three layers capture it — conceptually it complements OOP and handles horizontal behavior; mechanically it generates a runtime proxy to "weave in" advice; and its boundaries are method level, container beans only, with self-invocation failing. Next we open up the proxy itself to see how JDK and CGLIB actually build that stand-in.