DEV Community

Cover image for "🔇 Calling Your Own @Async Method Does Nothing"
Kyryl
Kyryl

Posted on

"🔇 Calling Your Own @Async Method Does Nothing"

Called this.notify(o) from inside placeOrder(), both methods on the same @Service. No exception. No warning. It just ran the mailer call inline, blocking the request thread like @Async was never there.

Here is the setup that broke:

@Service
class OrderService {

    void placeOrder(Order o) {
        save(o);
        this.notify(o); // bypasses the proxy
    }

    @Async
    void notify(Order o) {
        mailer.send(o); // never actually async
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing here looks wrong on a first read. notify() is annotated @Async, it lives on a @Service, the class is wired into the container correctly. And yet placeOrder() blocks on mailer.send() every single time. No thread pool, no exception, no log line telling you the annotation got ignored.

Why this.method() Skips the Proxy

Spring AOP does not rewrite your class. When you annotate a method with @Async, @Transactional, or @Cacheable, Spring wraps the bean in a proxy: a JDK dynamic proxy if the bean implements an interface, a CGLIB subclass otherwise. Every advice you rely on, the async executor handoff, the transaction interceptor, the cache lookup, lives on that proxy, not on your class.

When another bean calls orderService.notify(o), it is calling the proxy. The proxy runs its advice, then delegates to the real method. That is the whole mechanism working as designed.

When placeOrder() calls this.notify(o), there is no proxy in the picture. this inside a Spring-managed object is the raw, unproxied instance. It was never wrapped, because Spring only ever gets a chance to wrap the object other beans see when they ask the container for it. The object refers to itself directly, and Java resolves this.notify(o) as an ordinary virtual method call. Spring cannot intercept a call it never sees, because the call never leaves the object.

This is not a bug in Spring. It is a direct consequence of how proxy-based AOP works, and it applies to every annotation-driven aspect Spring ships: @Async, @Transactional, @Cacheable, @Retryable, custom @Aspect advice, all of it. Self-invocation quietly skips every one.

Fix 1: Split the Callee Into a Second Bean

The textbook answer is to make sure the call always crosses a real bean boundary. Pull the annotated method out into its own class:

@Service
class OrderService {
    private final NotifyService notifyService;

    void placeOrder(Order o) {
        save(o);
        notifyService.notify(o); // real proxy
    }
}

@Service
class NotifyService {
    @Async
    void notify(Order o) {
        mailer.send(o); // now actually async
    }
}
Enter fullscreen mode Exit fullscreen mode

This works. notifyService is a container-managed reference, Spring hands OrderService the proxy, and the call to notify() goes through it like any other cross-bean call. @Async fires, mailer.send() runs on the executor, placeOrder() returns immediately.

The cost is a class that has no reason to exist except to dodge this one behavior. NotifyService is not a domain concept. It does not group related responsibilities, it does not hide an implementation detail worth hiding, it exists purely because Spring's proxy model requires a bean boundary between caller and callee. Every time someone reads the codebase and asks "why is notification logic split out into its own service", the honest answer is "so a self-invocation bug does not resurface", which is not an answer that belongs in a design review.

For a genuinely separate concern, this split is the right call regardless of the AOP issue. For a single method that happens to need @Async, it is architecture shaped by a framework limitation, not by the domain.

Fix 2: Inject the Bean Into Itself

The pragmatic fix keeps the method where it belongs and instead gets a real proxy reference inside the class:

@Service
class OrderService {

    @Lazy @Autowired
    private OrderService self; // proxy to itself

    void placeOrder(Order o) {
        save(o);
        self.notify(o); // through the proxy, for real
    }

    @Async
    void notify(Order o) {
        mailer.send(o);
    }
}
Enter fullscreen mode Exit fullscreen mode

self is a field of the bean's own type, autowired like any other dependency. Spring resolves it to the actual proxy, the same object every other bean gets when it asks the container for an OrderService. Calling self.notify(o) instead of this.notify(o) sends the call through that proxy, the async advice runs, and mailer.send() finally executes on the executor instead of the caller's thread.

The @Lazy is not optional. Without it, Spring tries to fully construct OrderService, including resolving its self field, which means it needs a fully constructed OrderService to finish constructing OrderService. That is a circular dependency, and Spring fails at startup rather than silently accepting it. @Lazy tells Spring to inject a lazy proxy for self instead, one that only resolves the real bean the first time self.notify() (or any method on it) actually gets called, well after the container has finished wiring everything else. By then OrderService exists, the cycle never has to resolve eagerly, and startup succeeds.

The Honest Trade-off

Self-injection needs @Lazy to avoid that circular-dependency failure, which means the fix does not work by just adding a field, it works by adding a field plus an annotation whose job is to explain to Spring why injecting a bean into itself is not actually circular. Anyone unfamiliar with the trick reads @Lazy @Autowired private OrderService self; and reasonably assumes something is wrong with the code, because nothing about "inject the class into itself" looks intentional on a first pass.

It is still the better trade. Splitting notify() into NotifyService solves the same problem by growing the class hierarchy, and that growth has to be maintained forever: another file, another bean to wire in tests, another indirection for anyone tracing the call path, all to work around a framework detail that has nothing to do with what the domain actually looks like. The self-injected field is uglier at the point you read it, but it stays contained to the one class that needs it, and it does not force a design decision that only exists because of AOP.

Neither fix is free. Pick the one whose cost you would rather explain in a code review: a class that exists for no domain reason, or a field that looks wrong until someone tells you why it is there.

Ever shipped a silently-skipped @Async or @Transactional to production because of self-invocation? What finally gave it away?

Top comments (0)