skip to content

How does RequestMappingHandlerAdapter handle async return types (Callable, DeferredResult), and why is the HandlerAdapter abstraction valuable architecturally?

level: principalimportance: nice to knowfreq 18%

answer

  1. WebAsyncManager.startCallable/DeferredResult processing
  2. isConcurrentHandlingStarted → return null, free thread
  3. re-dispatch: wrapConcurrentResult, only return handled
  4. Open/Closed seam: supports()/handle()
  5. ThreadLocals (SecurityContext/MDC) don't auto-propagate

basics

~20 s

For 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 s

When 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
java
@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

for a junior

Know Spring MVC can return Callable/DeferredResult to process a request asynchronously.

for a middle

Explain that async starts via WebAsyncManager, frees the thread, and re-dispatches when the result is ready.

for a senior

Detail the null-ModelAndView short-circuit, wrapConcurrentResult re-handling only the return value, and timeout/error hooks.

for a principal

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).

context