Proxy Pattern & AOP
The proxy pattern puts a stand-in object in front of a real one to control access to it — adding behavior before, after, or around each call without changing the target. This is the engine behind Spring AOP and behind annotations like @Transactional, @Cacheable, and @Async. When you annotate a method, Spring quietly wraps your bean in a proxy that adds the cross-cutting behavior.
Why proxies — cross-cutting concerns
Transactions, caching, retries, security checks, and logging are cross-cutting concerns: they apply across many methods but are not the method’s real job. Scattering that code into every method is repetitive and error-prone. A proxy lets Spring inject the behavior from the outside, keeping your method focused on business logic.
@Service
public class AccountService {
@Transactional // Spring wraps this method in a tx proxy
public void transfer(Long from, Long to, BigDecimal amount) {
debit(from, amount);
credit(to, amount);
// commit on normal return; rollback on RuntimeException
}
}
You never wrote connection.commit() — the proxy did. See transactions for the semantics.
How the proxy is created
At startup, a BeanPostProcessor examines each bean. If a method carries an AOP-relevant annotation (or matches a pointcut), Spring replaces the bean reference in the container with a proxy that has the same type. Callers inject the proxy and never see the raw object.
Spring chooses between two proxy mechanisms:
| Mechanism | When used | How it works | Limitation |
|---|---|---|---|
| JDK dynamic proxy | Bean implements an interface | Generates a proxy implementing the same interface(s) | Only interface-declared methods are advised |
| CGLIB | Bean has no interface (or proxyTargetClass=true) | Generates a runtime subclass that overrides methods | Cannot proxy final classes/methods |
Spring Boot defaults to CGLIB (proxy-target-class=true) so proxying works whether or not your bean implements an interface.
caller ──▶ [ AccountService$$Proxy ] ──▶ begin tx
──▶ real AccountService.transfer(...)
──▶ commit / rollback
Annotations that ride on proxies
The same proxy machinery powers several familiar annotations:
@Service
public class CatalogService {
@Cacheable("products") // result cached by the proxy
public Product findById(Long id) {
return slowLookup(id);
}
@Async // run on another thread by the proxy
public CompletableFuture<Report> buildReport() {
return CompletableFuture.completedFuture(new Report());
}
}
@Cacheable checks the cache before delegating; @Async submits the call to an executor. Both are pure proxy behavior added around your method. See async for @Async details.
The self-invocation pitfall
Because the behavior lives in the proxy, it only applies to calls that go through the proxy — i.e. calls from another bean. A method calling another annotated method on this bypasses the proxy entirely, so the annotation does nothing.
@Service
public class ReportService {
public void generateAll() {
for (Long id : ids()) {
buildOne(id); // BUG: internal call — @Transactional is IGNORED
}
}
@Transactional
public void buildOne(Long id) { /* ... */ }
}
generateAll() calls buildOne() directly on this, not on the proxy, so each buildOne runs with no transaction. The same trap applies to @Cacheable and @Async.
Fixes:
- Move the annotated method to a different bean and inject it (the clean, preferred fix).
- Inject the proxy of yourself via
ObjectProvideror@Lazy AccountService selfand callself.buildOne(id). - Use
AopContext.currentProxy()(requiresexposeProxy = true) — works but couples code to AOP.
Warning: Self-invocation silently disabling
@Transactionalis one of the most common Spring bugs. If a transaction “isn’t rolling back,” check whether the annotated method is being called from within the same class.
Note: Proxy-based AOP only intercepts public methods reached through a Spring-managed bean.
private,static, andfinalmethods cannot be advised by the proxy. If you need to advise those, you would have to switch to compile/load-time weaving with AspectJ.
Writing your own aspect
You can add custom cross-cutting behavior with @Aspect, using the same proxy machinery:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TimingAspect {
@Around("@annotation(Timed)")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // call the real method
} finally {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println(pjp.getSignature() + " took " + ms + "ms");
}
}
}
Requires spring-boot-starter-aop. The aspect runs around every method annotated @Timed — a decorator/proxy you defined yourself.