@EnableAsync is the switch that makes @Async real — it registers the post-processor that wraps @Async beans in a proxy — and because it's proxy-based, the self-invocation trap is identical to @Transactional: calling an @Async method from within the same class runs it synchronously on the caller's thread. No proxy is crossed, so no async dispatch happens.
What @EnableAsync does
Put it on a @Configuration class (Boot enables it for you when appropriate, but it's good to be explicit):
@Configuration
@EnableAsync
public class AsyncConfig { }
It imports AsyncAnnotationBeanPostProcessor, which scans beans for @Async and replaces each with a proxy that intercepts calls and submits them to the TaskExecutor. Without @EnableAsync, @Async is just a no-op annotation — the method runs inline.
The self-invocation problem
@Service
public class OrderService {
public void process(Order o) {
validate(o);
audit(o); // BUG: runs on THIS thread, not async
}
@Async
public void audit(Order o) { ... }
}
this.audit(o) calls the raw object, not the proxy. The proxy only intercepts calls that arrive from outside the bean (e.g. a controller calling orderService.audit(...)). An internal this. call bypasses it entirely, so @Async (and @Transactional, @Cacheable, etc.) silently does nothing.
Fixes
- Move the async method to another bean and inject it — the cleanest fix. The cross-bean call goes through that bean's proxy.
@Service
public class OrderService {
private final AuditService audit; // separate bean
public void process(Order o) { validate(o); audit.record(o); } // proxied → async
}
- Self-inject the proxy and call through it (works but smells):
@Autowired private OrderService self;
public void process(Order o) { self.audit(o); } // crosses the proxy