Enterprise Java

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.

PropertyJDK Dynamic ProxyCGLIB Proxy
Requires an interfaceYes, mandatoryNo — works on any non-final class
MechanismImplements the interfaceGenerates a runtime subclass
Can proxy private methodsNoNo
Can proxy final methodsN/A — not part of the interfaceNo — cannot override a final method
Works on a final classYes, since the class isn’t extendedNo — cannot subclass a final class
Inject/cast by concrete classNot directly supportedYes, since the proxy is a subclass
Spring Proxies
JDK dynamic proxies and CGLIB proxies compared across the properties that actually cause the four failure modes below.

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

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.

CaseRoot causeDetectability
Self-invocationCall bypasses the proxy via thisFully silent
Final classCGLIB cannot subclass itHard failure at startup
Final methodCGLIB cannot override itSilent (debug-level warning only)
Private methodNot inheritable/overridable by either proxy typeFully silent
Unmanaged bean (new)No proxy was ever createdFully silent
How detectable each failure mode is without deliberately checking for it. Only the final-class case fails loudly enough to be caught by default.

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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button