How Spring Proxies Actually Work — And the Four Cases Where They Silently Don’t
A deep-dive into how @Transactional, @Cacheable, and @Async use CGLIB or JDK dynamic proxies — and the four well-known failure modes that swallow the annotation silently with no error.
An annotation like @Transactional works right up until, one refactor later, it quietly doesn’t — and in three of the four cases below, nothing in your logs will tell you so. No exception. No warning at startup. The transaction just never opens, the cache is never checked, the method just runs like any other. Understanding why requires understanding what a Spring proxy actually is, mechanically, rather than treating the annotation as declarative magic.
The Two Proxy Mechanisms Spring Actually Uses
When Spring sees a bean with an annotation like @Transactional, @Cacheable, or @Async, it doesn’t modify your class. It wraps it. The object your other beans actually hold a reference to isn’t your class — it’s a generated proxy object that sits in front of it, intercepts the annotated method call, runs the relevant logic (opening a transaction, checking a cache, submitting to an executor), and only then delegates to your real object underneath.
Spring builds that wrapper one of two ways. If the target class implements at least one interface, Spring can generate a JDK dynamic proxy — a class generated at runtime that implements the same interface and forwards calls through an invocation handler. If there’s no interface, Spring falls back to CGLIB, which works by generating an actual subclass of your class at runtime and overriding its methods to insert the interception logic. Since Spring Boot 2.0, CGLIB has been the default even when an interface is present, specifically because subclass-based proxies let you inject and cast to the concrete type without surprises.
| Property | JDK Dynamic Proxy | CGLIB Proxy |
|---|---|---|
| Requires an interface | Yes, mandatory | No — works on any non-final class |
| Mechanism | Implements the interface | Generates a runtime subclass |
| Can proxy private methods | No | No |
| Can proxy final methods | N/A — not part of the interface | No — cannot override a final method |
| Works on a final class | Yes, since the class isn’t extended | No — cannot subclass a final class |
| Inject/cast by concrete class | Not directly supported | Yes, since the proxy is a subclass |

Case 1: Self-Invocation
The most common of the four, and the one that trips up developers who understand proxies perfectly well in the abstract but forget the mechanical consequence: calling an annotated method from another method in the same class using this never touches the proxy at all.
@Service
public class OrderService {
public void placeOrder(Order order) {
// this call goes straight to the real object — no proxy involved
this.chargeCustomer(order);
}
@Transactional
public void chargeCustomer(Order order) {
paymentRepository.charge(order);
}
}
When another bean calls orderService.placeOrder(order), it’s calling through the proxy, as intended. But once execution is inside placeOrder, the call to this.chargeCustomer(order) is a plain Java method call on the real, unwrapped object — the JVM has no reason to route it back out through the proxy, because from the object’s own perspective, it doesn’t know it’s being proxied at all. @Transactional on chargeCustomer is completely inert in this call path.
The fix that scales best is to move chargeCustomer into its own bean and call it through a normal dependency-injected reference, so the call genuinely goes through a proxy. Where that’s impractical, self-injecting a lazy reference to the proxy works too:
@Service
public class OrderService {
private final OrderService self;
public OrderService(@Lazy OrderService self) {
this.self = self;
}
public void placeOrder(Order order) {
self.chargeCustomer(order); // now goes through the proxy
}
@Transactional
public void chargeCustomer(Order order) {
paymentRepository.charge(order);
}
}
Case 2: Final Classes and Final Methods — Two Different Outcomes
These two look like the same rule but behave differently in practice, and conflating them is where a lot of confusion comes from. A final class cannot be subclassed at all, so CGLIB fails to create the proxy in the first place — this actually surfaces as a hard startup failure, not a silent one, since Spring throws an exception while trying to build the application context.
@Service
public final class PricingService {
@Cacheable("prices")
public BigDecimal calculatePrice(String sku) {
return pricingRepository.lookup(sku);
}
}
// Fails at context startup: CGLIB cannot subclass a final class
A final method on an otherwise proxyable class is the genuinely silent case: CGLIB can still build the proxy, it simply cannot override that one final method, so the proxy quietly falls back to the real method with no interception applied to it specifically.
@Service
public class InventoryService {
@Transactional
public final void reserveStock(String sku, int quantity) {
// proxy exists, but this specific method is never intercepted
inventoryRepository.decrement(sku, quantity);
}
}
It is worth knowing: Spring does log a warning for the final-method case — but only at a log level most applications never surface in production, which is functionally silent for anyone not actively watching debug-level AOP logs.
Case 3: Private Methods
Both proxy mechanisms fail here for the same underlying reason: a private method isn’t inherited, and both a CGLIB subclass and a JDK interface implementation rely on inheritance or interface conformance to intercept a call. There’s no override possible, so there’s nothing for the proxy to hook into — the annotation is simply never wired up to anything.
@Service
public class ReportService {
public Report generate(String id) {
return buildReport(id);
}
@Cacheable("reports")
private Report buildReport(String id) {
// never cached — private methods can't be proxied at all
return reportRepository.build(id);
}
}
The fix is simply visibility: making the method package-private, protected, or public restores the proxy’s ability to intercept it, assuming the call itself also comes from outside the class (see Case 1 if it doesn’t).
Case 4: Beans Not Managed by Spring
The most conceptually simple case, and the easiest to miss in practice: a proxy only exists because Spring’s container created it in place of your object. Anything instantiated directly with new — inside a test, inside a factory method, inside another class’s constructor — was never touched by Spring’s bean post-processing at all, so there’s no proxy, full stop.
public class NotificationSender {
public void notifyOrderShipped(Order order) {
// Created directly — Spring never sees this instance
EmailService emailService = new EmailService();
emailService.sendConfirmation(order); // @Async on this method does nothing
}
}
Every annotation on EmailService is syntactically valid and semantically dead, because the object executing sendConfirmation is a plain Java object that Spring’s container has no relationship with whatsoever. This is common in older codebases that predate consistent dependency injection discipline, and in test code that constructs collaborators manually instead of using Spring’s test context.
| Case | Root cause | Detectability |
|---|---|---|
| Self-invocation | Call bypasses the proxy via this | Fully silent |
| Final class | CGLIB cannot subclass it | Hard failure at startup |
| Final method | CGLIB cannot override it | Silent (debug-level warning only) |
| Private method | Not inheritable/overridable by either proxy type | Fully silent |
Unmanaged bean (new) | No proxy was ever created | Fully silent |

A Practical Checklist Before Trusting a Proxied Annotation
Before assuming @Transactional, @Cacheable, or @Async is actually active, verify:
- Is this method being called from outside the class, through a real dependency-injected reference — not via
this? - Is the method (and its class) free of
final, or have you deliberately confirmed CGLIB can still proxy it? - Is the method at least package-private, not
private? - Was the object actually obtained from Spring’s container — autowired or looked up — rather than constructed with
new? - If in doubt, has
AopUtils.isAopProxy(bean)actually been checked at runtime to confirm a proxy exists at all?
What We Learned
Spring’s declarative annotations work by wrapping your bean in a generated proxy — either a JDK dynamic proxy built from an interface, or a CGLIB subclass built from your class directly — and every one of the four failure modes traces back to a call path that never actually passes through that wrapper. Self-invocation, private methods, and unmanaged beans fail completely silently, with no exception and no log to catch them; final methods fail almost as silently, logged only at a level nobody watches; only a final class fails loudly, because CGLIB can’t even construct the proxy in the first place. None of these are bugs in Spring — they’re the direct, mechanical consequence of what a proxy actually is, and once that mechanism is visible, “why isn’t my transaction rolling back” stops being mysterious and becomes a five-item checklist.




