How does RequestMappingHandlerAdapter handle async return types (Callable, DeferredResult), and why is the HandlerAdapter abstraction valuable architecturally?
answer
- WebAsyncManager.startCallable/DeferredResult processing
- isConcurrentHandlingStarted → return null, free thread
- re-dispatch: wrapConcurrentResult, only return handled
- Open/Closed seam: supports()/handle()
- ThreadLocals (SecurityContext/MDC) don't auto-propagate
basics
~20 sFor async return types the adapter starts async processing via WebAsyncManager, returns a null ModelAndView, and lets the servlet container release the thread. When the result is ready the request is re-dispatched and only the return-value handling re-runs. The abstraction keeps DispatcherServlet closed to modification but open to new handler types.
solid answer
~40 sWhen a handler returns a Callable, DeferredResult, CompletableFuture, or reactive type, invokeAndHandle's return-value handler (e.g. CallableMethodReturnValueHandler / DeferredResultMethodReturnValueHandler) registers the work with the request's WebAsyncManager and starts async processing (startCallableProcessing / startDeferredResultProcessing). invokeHandlerMethod then checks asyncManager.isConcurrentHandlingStarted() and returns null, so the container thread is freed while the async result is pending. When the result completes, the container re-dispatches the request; the adapter detects the concurrent result, wraps the invocable so only the return value is re-processed through the normal return-value handlers, and produces the final ModelAndView. Architecturally, HandlerAdapter is an Open/Closed extension seam: DispatcherServlet depends only on supports()/handle(), so new handler kinds (annotated, functional, async) are added by registering adapters, never by editing the front controller — clean separation of dispatch orchestration from invocation strategy.
code
java · 22 lines@RestController
class ReportController {
private final AsyncTaskExecutor executor;
// Container thread is freed; the Callable runs on the executor.
@GetMapping("/report")
Callable<ReportDto> report() {
return () -> heavyReport(); // result re-dispatched when ready
}
// Completed externally (e.g. by a message listener), no polling.
@GetMapping("/quote")
DeferredResult<Quote> quote() {
DeferredResult<Quote> dr = new DeferredResult<>(5000L);
quoteService.subscribeOnce(dr::setResult); // another thread sets it
dr.onTimeout(() -> dr.setErrorResult(ResponseEntity.status(504).build()));
return dr;
}
}
// Gotcha: SecurityContextHolder / MDC set on the request thread are NOT
// visible inside heavyReport() unless the executor propagates them
// (e.g. DelegatingSecurityContextAsyncTaskExecutor).go deeper
Know Spring MVC can return Callable/DeferredResult to process a request asynchronously.
Explain that async starts via WebAsyncManager, frees the thread, and re-dispatches when the result is ready.
Detail the null-ModelAndView short-circuit, wrapConcurrentResult re-handling only the return value, and timeout/error hooks.
Frame HandlerAdapter as an Open/Closed seam, discuss ThreadLocal propagation pitfalls, and draw the WebFlux parallel.
## Two questions in one: the async mechanics, and why the seam exists. ### Async return types Spring MVC supports non-blocking-ish request handling on the Servlet container via **`WebAsyncManager`** (bound to the request). The adapter enables the relevant return-value handlers when it configures the invocable in `invokeHandlerMethod`: - `Callable<T>` → `CallableMethodReturnValueHandler` → `WebAsyncManager.startCallableProcessing(...)`. The `Callable` is submitted to an `AsyncTaskExecutor`; the container thread returns immediately. - `DeferredResult<T>` / `ListenableFuture` / `CompletableFuture` → `DeferredResultMethodReturnValueHandler` → `startDeferredResultProcessing(...)`. Completion is driven externally (another thread sets the result). - `ResponseBodyEmitter` / `SseEmitter` / `StreamingResponseBody` → their own handlers for streaming. - Reactive types (`Mono`/`Flux`) are adapted via `ReactiveTypeHandler`. **Flow:** 1. First dispatch: `invokeAndHandle` runs, the async return-value handler starts async processing on the `WebAsyncManager`, and `mavContainer` is left such that `invokeHandlerMethod` sees `asyncManager.isConcurrentHandlingStarted() == true` and returns **`null`**. `DispatcherServlet` returns without rendering; the Servlet container puts the request into async mode (`AsyncContext`), freeing the HTTP worker thread. 2. Result ready: the container **re-dispatches** the same request (an `ASYNC` dispatch). `RequestMappingHandlerAdapter.invokeHandlerMethod` runs again but now `asyncManager.hasConcurrentResult()` is true. It calls `handlerMethod = handlerMethod.wrapConcurrentResult(result)` so the *already-computed value* is fed straight into the normal `HandlerMethodReturnValueHandlerComposite` (e.g. serialized via `RequestResponseBodyMethodProcessor`). Argument resolution and controller invocation are **not** repeated. 3. `getModelAndView` then produces the final `ModelAndView` (or null for a body). Gotchas: timeouts and errors go through `DeferredResult.onTimeout/onError` or the async `AsyncListener`; exceptions on re-dispatch are routed to the usual `HandlerExceptionResolver`s. Thread-bound state (`SecurityContextHolder`, `ThreadLocal`s, `@RequestScope`) does **not** automatically propagate to the async task thread unless you configure it (e.g. `DelegatingSecurityContextAsyncTaskExecutor`, `RequestContextFilter`). This is a classic production bug. ### Why the HandlerAdapter abstraction is valuable - **Open/Closed Principle**: `DispatcherServlet` is *closed for modification* but the set of handler kinds is *open for extension*. It depends only on `supports()`/`handle()`. Supporting annotated controllers, `Controller` beans, `HttpRequestHandler`, and functional `RouterFunction` endpoints is a matter of registering more `HandlerAdapter`s. - **Single Responsibility**: mapping (`HandlerMapping`), invocation (`HandlerAdapter`), and rendering (`ViewResolver`) are separable concerns, each independently testable and replaceable. - **Uniform lifecycle with variable strategy**: interceptors, exception resolution, and view rendering are shared; only the *invocation strategy* varies per adapter. `RequestMappingHandlerAdapter` internally repeats the pattern with the argument-resolver and return-value-handler composites — strategy lists all the way down. - **Testability / evolution**: WebFlux mirrors the same shape (`HandlerAdapter` in `org.springframework.web.reactive.result`), which is why the mental model transfers. New paradigms (coroutines, reactive) slot in as new adapters/handlers without disturbing the core dispatch loop. ### When to care You reason at this level when designing custom handler infrastructure, debugging why `SecurityContext`/MDC is missing in an async controller, or explaining framework extensibility and the WebFlux parallel in a system-design/architecture interview.
- On the async re-dispatch, is the controller method invoked a second time?No. The adapter detects asyncManager.hasConcurrentResult() and calls wrapConcurrentResult, feeding the already-computed value directly into the return-value handlers. Argument resolution and the controller method invocation are not repeated.
- A user's Authentication is null inside a Callable-returning controller's async work. Why, and how do you fix it?SecurityContextHolder uses a ThreadLocal that lives on the original request thread, not the task-executor thread. Fix by propagating it — e.g. wrap the executor with DelegatingSecurityContextAsyncTaskExecutor or set MODE_INHERITABLETHREADLOCAL / use the security async integration.
- How does this abstraction map to WebFlux?WebFlux has its own HandlerAdapter hierarchy (reactive package) with RequestMappingHandlerAdapter and HandlerFunctionAdapter analogues; the same supports()/handle() seam lets the reactive DispatcherHandler dispatch without knowing handler shapes, so the mental model carries over.
saying these in an interview costs you the question
- Saying the controller method re-runs entirely on async completion (only the return value is re-handled).
- Assuming SecurityContext/MDC/ThreadLocals propagate automatically to the async executor thread.
- Claiming async return frees the thread but keeps a real ModelAndView (it returns null and re-dispatches).