Files
spring-async-demo/async/docs/02-the-self-invocation-trap.md
Ankur Mhatre 243cccd4ca Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:27:47 +05:30

2.6 KiB

prev: What @Async actually does · README · next: What the proxy cannot see

2. The self-invocation trap

The proxy wraps the bean. It does not replace this.

@Async
public CompletableFuture<String> annotated() { ... }

public CompletableFuture<String> viaSelfInvocation() {
    return annotated();          // this.annotated() — the proxy is not involved
}

When another bean calls service.annotated(), it holds a reference to the proxy, so the call is intercepted. When viaSelfInvocation() calls annotated(), the compiler emits this.annotated(), and this inside the method body is the target object, not the proxy. There is no interception, so the method runs inline and the CompletableFuture you get back is already complete.

Measured, in docs/output/self-invocation.txt:

service.annotated()          : task-5 (virtual=false)
service.viaSelfInvocation()  : main (virtual=false)
service.viaSelfReference()   : task-6 (virtual=false)

Nothing is logged. The method returns a valid future with the correct value in it. The only symptom is that the caller waited.

The fixes, in the order you should prefer them

Move the method to another bean. If work is asynchronous, it belongs behind a boundary, and the boundary is a good place for a class. This is the fix that survives a refactor.

Go back out through the proxy on purpose. SelfInvocationService does this with an ObjectProvider<SelfInvocationService>:

public CompletableFuture<String> viaSelfReference() {
    return self.getObject().annotated();
}

ObjectProvider is lazy, so there is no circular dependency to resolve at construction time. Injecting SelfInvocationService directly also works — Spring special-cases self-references — but it looks like a bug to every reviewer who has not read this chapter.

@EnableAsync(mode = AdviceMode.ASPECTJ) removes the limitation entirely, because load-time weaving rewrites the method itself rather than wrapping the object. It also adds an agent to your startup and a weaving configuration to your build. Very few applications should pay that.

Why this keeps happening

The trap is not that people do not know about proxies. It is that the calling code often did not start out in the same class. A method gets extracted, a controller's logic moves into the service that already had the @Async method, and one day a call that used to arrive from outside arrives from inside. Nothing fails. The throughput just changes.

next: What the proxy cannot see