skip to content

For a @RestController method returning a DTO, why does HandlerAdapter.handle ultimately return a null ModelAndView, and where is the JSON actually written?

level: seniorimportance: should knowfreq 30%

answer

  1. @RestController = @Controller + @ResponseBody
  2. RequestResponseBodyMethodProcessor writes body
  3. HttpMessageConverter (Jackson) serializes JSON
  4. setRequestHandled(true) → getModelAndView null
  5. null MAV → DispatcherServlet skips view rendering

basics

~10 s

@ResponseBody sends the return value to RequestResponseBodyMethodProcessor, which serializes it with an HttpMessageConverter and marks the request as handled. Because the response is already written, no view is needed, so getModelAndView returns null.

solid answer

~40 s

@RestController implies @ResponseBody on every method. During invokeAndHandle, the DTO return value is dispatched to the HandlerMethodReturnValueHandler that supports it: RequestResponseBodyMethodProcessor. It selects a matching HttpMessageConverter (e.g. MappingJackson2HttpMessageConverter) via content negotiation, writes JSON directly to the HttpServletResponse's output stream, and calls mavContainer.setRequestHandled(true). Back in invokeHandlerMethod, getModelAndView checks mavContainer.isRequestHandled(); since it is true, it returns null. DispatcherServlet sees a null ModelAndView from HandlerAdapter.handle and therefore skips view resolution and rendering entirely — the response is already committed. This is the mechanism that separates the REST/body path from the view path, and it explains why a null return from handle() is normal rather than an error for @ResponseBody endpoints.

code

java · 16 lines
java
@RestController
@RequestMapping("/api/users")
class UserController {
    @GetMapping
    List<UserDto> list() {           // no view name — @ResponseBody is implicit
        return userService.all();     // becomes JSON via HttpMessageConverter
    }
}

// Effective chain:
// invokeAndHandle -> RequestResponseBodyMethodProcessor.handleReturnValue
//   -> content negotiation picks application/json
//   -> MappingJackson2HttpMessageConverter.write(list, ...)
//   -> mavContainer.setRequestHandled(true)
// invokeHandlerMethod.getModelAndView -> returns null (request already handled)
// DispatcherServlet -> no ViewResolver invoked

go deeper

for a junior

Know @RestController writes the object as JSON and no HTML view is rendered.

for a middle

Identify RequestResponseBodyMethodProcessor + HttpMessageConverter as the writers and that no view resolver runs.

for a senior

Explain setRequestHandled(true) driving the null ModelAndView and the branch back in DispatcherServlet, plus content negotiation.

for a principal

Reason about converter ordering, ResponseBodyAdvice, committed-response implications for interceptors, and ResponseEntity vs @ResponseBody paths.

## Setup `@RestController` = `@Controller` + `@ResponseBody`. So every handler method's return value is treated as the response *body*, not a *view name*. ## The flow for `List<UserDto> list()` 1. `RequestMappingHandlerAdapter.invokeHandlerMethod` builds the `ServletInvocableHandlerMethod` and calls `invokeAndHandle`. 2. `invokeForRequest` resolves args and reflectively invokes the method, producing the DTO (or collection). 3. `handleReturnValue` asks each `HandlerMethodReturnValueHandler.supportsReturnType`. For a `@ResponseBody` method, **`RequestResponseBodyMethodProcessor`** returns true. 4. `RequestResponseBodyMethodProcessor.handleReturnValue`: - `mavContainer.setRequestHandled(true)` — declares the request fully handled. - Runs **content negotiation** (`ContentNegotiationManager`) to pick a media type (e.g. `application/json`). - Selects a compatible `HttpMessageConverter` whose `canWrite(type, mediaType)` is true — typically `MappingJackson2HttpMessageConverter`. - Calls `converter.write(body, mediaType, outputMessage)`, which serializes to JSON straight onto the `HttpServletResponse` output stream. Honors `@ResponseStatus`, `ResponseBodyAdvice`, etc. 5. Back in `invokeHandlerMethod`, `getModelAndView(mavContainer, ...)` sees `mavContainer.isRequestHandled() == true` and returns **`null`**. 6. `DispatcherServlet.doDispatch` receives `null` from `ha.handle(...)`, so `processDispatchResult` finds no view to render and finishes. ## Why the null is meaningful, not an error `HandlerAdapter.handle`'s contract is 'return a `ModelAndView`, or `null` if handling is complete'. A `null` here specifically means: the handler already wrote the response, do not attempt view resolution. Contrast with a classic MVC controller returning `"userList"` — there `ViewNameMethodReturnValueHandler` sets a view name on the container, `requestHandled` stays false, and `getModelAndView` returns a real `ModelAndView` for the `ViewResolver` to render. ## Edge cases / gotchas - **`ResponseEntity`** goes through `HttpEntityMethodProcessor` instead — same idea, but it also lets you set status and headers; still ends with `requestHandled=true` and a null ModelAndView. - **`void` + directly writing to the response** (e.g. taking `HttpServletResponse` and writing) also results in a null ModelAndView because Spring marks the request handled when the response is committed / a `ServletResponse` arg was declared. - **No acceptable converter** → `HttpMediaTypeNotAcceptableException` (406). **Unreadable body on input** → `HttpMessageNotReadableException` (400). These are converter-layer failures, not adapter failures. - The JSON is written *during* `handle()`, so response headers/status are committed before `DispatcherServlet` regains control — you cannot change status in a later interceptor `postHandle` for streamed bodies. ## When to care This explains REST behavior end to end: no view resolver is consulted for `@RestController`, `HttpMessageConverter` ordering/content negotiation decides serialization, and interceptor `postHandle` runs but can't alter an already-written body.

  • How would the same return value be handled if the method returned a String on a plain @Controller (no @ResponseBody)?
    ViewNameMethodReturnValueHandler treats the String as a logical view name, sets it on the ModelAndViewContainer with requestHandled=false, so getModelAndView returns a real ModelAndView and DispatcherServlet resolves/renders that view.
  • Which component decides JSON vs XML for the body?
    Content negotiation (ContentNegotiationManager, driven by the Accept header / path extension / params) picks the media type; then the first HttpMessageConverter whose canWrite matches that type serializes it.

saying these in an interview costs you the question

  • Saying a ViewResolver renders the JSON for @RestController (no view is involved).
  • Treating the null ModelAndView as an error or a 204 signal.
  • Believing the adapter itself serializes JSON rather than delegating to an HttpMessageConverter.

context