02-web-rest-apis

Spring MVC: The Request Lifecycle Behind @RestController

Follow one request through DispatcherServlet, HandlerMapping and the message converters, then use that model to bind parameters and pick status codes.

October 8, 2026
spring-bootspring-mvcDispatcherServletRestControllerResponseEntityrest-apihttp

One Annotation, A Lot of Machinery

This is a complete, working REST endpoint:

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
 
    private final OrderService service;
 
    public OrderController(OrderService service) {
        this.service = service;
    }
 
    @GetMapping("/{id}")
    public Order findById(@PathVariable UUID id) {
        return service.findById(id);
    }
}

An HTTP GET arrives, a UUID materialises as a method parameter, an Order object becomes a JSON response body with Content-Type: application/json. Nothing in this class mentions HTTP parsing, JSON, or status codes.

That convenience is worth understanding rather than just using. Every awkward web-layer bug — a 406 you didn't ask for, a @PathVariable that arrives null, a date serialised as an array of numbers, a @Valid that doesn't validate — lives somewhere in the chain between the socket and your method. This guide walks that chain once, then uses it as the explanation for everything else.

If you want the broader REST design view — resource naming, versioning strategy, status-code discipline — the REST APIs chapter in the backend engineer track covers it. Here the focus is the Spring MVC mechanism underneath.

The DispatcherServlet Chain

Spring MVC is a front controller design. One servlet receives every request and delegates. That servlet is DispatcherServlet, and spring-boot-starter-web registers it at / for you — this is one of the auto-configurations from phase 1.

Four stages matter in practice.

1. HandlerMapping — find the method. RequestMappingHandlerMapping holds a registry built at startup by scanning every @RequestMapping in the context. It matches on path, HTTP method, headers, content type and producible type. No match means 404; a path match with the wrong verb means 405; a path match whose consumes doesn't fit the request's Content-Type means 415; one whose produces doesn't satisfy the Accept header means 406. Those four status codes are decided here, before your code runs — which is why you can't debug them from inside your method.

2. Argument resolution. HandlerMethodArgumentResolver implementations inspect each parameter and produce a value — one resolver for @PathVariable, another for @RequestParam, another for @RequestBody. This is an extensible list, which is how Spring Security injects Authentication and how Pageable appears in Spring Data controllers.

3. Your method runs. The only part you wrote.

4. Return value handling and conversion. @ResponseBody (implied by @RestController) routes the return value through the HttpMessageConverter chain, which picks a converter based on the return type and the negotiated content type.

💡

@RestController is @Controller + @ResponseBody. The @ResponseBody half is what diverts the return value into a message converter instead of treating it as a view name. On a plain @Controller, returning the string "orders" means render the view called orders; on a @RestController it means the response body is the text orders. Mixing the two up is the source of the classic "Circular view path" error.

Mapping Requests

@RequestMapping at class level sets a prefix; the verb-specific annotations are shortcuts for the method level:

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
 
    @GetMapping                       // GET /api/orders
    List<Order> list() { }
 
    @GetMapping("/{id}")              // GET /api/orders/{id}
    Order findById(@PathVariable UUID id) { }
 
    @PostMapping                      // POST /api/orders
    ResponseEntity<Order> create(@RequestBody NewOrderRequest request) { }
 
    @PutMapping("/{id}")              // full replacement
    Order replace(@PathVariable UUID id, @RequestBody OrderRequest request) { }
 
    @PatchMapping("/{id}")            // partial update
    Order update(@PathVariable UUID id, @RequestBody JsonPatch patch) { }
 
    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable UUID id) { }
}

@GetMapping is exactly @RequestMapping(method = GET). Use the specific forms — they are shorter and they make the verb visible at a glance.

Narrow a mapping further when you need to:

java
@PostMapping(consumes = "application/json", produces = "application/json")
@GetMapping(params = "status")              // only when ?status= is present
@GetMapping(headers = "X-Tenant")           // only with that header

Spring Boot 4 adds first-class API versioning to this set, so a version becomes a mapping condition rather than a path convention you maintain by hand:

java
@GetMapping(version = "1.0")
Order findByIdV1(@PathVariable UUID id) { }
 
@GetMapping(version = "2.0")
OrderV2 findByIdV2(@PathVariable UUID id) { }

Where the version is read from — header, query parameter, media-type parameter or path segment — is configuration, not code. Our Spring Boot 4 write-up covers the mechanism; the point here is that it's a HandlerMapping condition like any other.

Binding: Four Sources, Four Annotations

AnnotationReads fromExample
@PathVariableA {placeholder} in the path/orders/42 → 42
@RequestParamQuery string or form body?status=PAID → "PAID"
@RequestBodyThe request body, via a converterJSON → an object
@RequestHeaderA headerX-Tenant: acme → "acme"
java
@GetMapping("/{id}/items")
List<Item> items(
        @PathVariable UUID id,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(required = false) String status,
        @RequestHeader(value = "X-Tenant", required = false) String tenant) {
}

Three details that cause real trouble:

Optionality has three forms, and they differ. @RequestParam String s is mandatory — absent means 400. required = false gives null. defaultValue gives a value and implies required = false. Prefer defaultValue for paging and Optional<String> or required = false for genuinely optional filters, but be deliberate: a mandatory parameter failing loudly is usually what you want.

Parameter names can vanish. @PathVariable UUID id relies on the parameter being named id in the bytecode. Without -parameters at compile time, that name is lost and you get IllegalArgumentException about an unresolvable name. Spring Boot's Maven and Gradle plugins set the flag for you, so this is mostly a hand-rolled-build problem — and the fix is either the compiler flag or being explicit: @PathVariable("id").

Binding a whole object has no annotation. A non-annotated complex parameter is treated as a model attribute and bound field-by-field from query and form data:

java
@GetMapping("/search")
List<Order> search(OrderSearchCriteria criteria) { }   // ?status=PAID&from=2026-01-01

Tidier than six @RequestParams, and the criteria object can carry validation annotations.

⚠️

Model-attribute binding is the mechanism behind the mass-assignment class of bug. Bind to a purpose-built request record with exactly the fields the endpoint should accept — never to a JPA entity. Bind to an entity and a request that sets ?role=ADMIN or ?id=... may populate a field you never intended to expose. The same argument applies to @RequestBody: a dedicated DTO is a whitelist, and an entity is not.

ResponseEntity: When You Need the Envelope

Return a plain object and you get 200 with that object as the body — correct for most reads. ResponseEntity<T> gives you status, headers and body:

java
@PostMapping
ResponseEntity<Order> create(@RequestBody NewOrderRequest request) {
    Order created = service.create(request);
    return ResponseEntity
            .created(URI.create("/api/orders/" + created.id()))   // 201 + Location
            .body(created);
}
 
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable UUID id) {
    service.delete(id);
    return ResponseEntity.noContent().build();                     // 204
}

Two conventions worth holding to. A creation returns 201 with a Location header, because a client that just created something needs to know where it now lives. A delete returns 204 with no body.

Avoid the shape where a method returns ResponseEntity<?> and switches internally between a success body and an error body — that is what the exception handling in guide 3 of this phase is for. Keep the happy path in the controller; let errors become exceptions.

✅

void plus @ResponseStatus(HttpStatus.NO_CONTENT) reads well when a method has no interesting response. The annotation is also useful on a custom exception class — a quick way to map it to a status without writing a handler, though a @ControllerAdvice gives you a structured body too.

Check yourself

An endpoint is declared @GetMapping(value = '/{id}', produces = 'application/json'). A client requests it with Accept: application/xml. What happens?

Message Converters: Where Objects Become Bytes

An HttpMessageConverter translates between Java objects and the wire in both directions. The chain is ordered; the first converter that supports the type and media type wins.

With spring-boot-starter-web you get MappingJackson2HttpMessageConverter for JSON, plus converters for strings, byte arrays and resources. Adding jackson-dataformat-xml to the classpath adds an XML converter — and now the same controller serves both, selected by the Accept header. That is content negotiation, and guide 4 covers it together with Jackson configuration.

This explains two things that otherwise look arbitrary:

  • Returning String from a @RestController is not JSON. StringHttpMessageConverter sits earlier in the chain and writes the raw characters as text/plain. Returning "OK" produces OK, not "OK". Return a record or a Map if you want a JSON body.
  • @RequestBody failures are 400, not 500. A malformed JSON body fails inside the converter with HttpMessageNotReadableException, which Spring MVC already maps to 400 — before your method is entered.

Where Exceptions Go

If your method throws, DispatcherServlet hands the exception to its HandlerExceptionResolver chain. DefaultHandlerExceptionResolver already maps the framework's own exceptions sensibly:

ExceptionStatus
HttpRequestMethodNotSupportedException405
HttpMediaTypeNotSupportedException415
HttpMediaTypeNotAcceptableException406
MissingServletRequestParameterException400
MethodArgumentNotValidException400
HttpMessageNotReadableException400
NoResourceFoundException404

So the framework's own errors are already correct. What it cannot know is what your OrderNotFoundException means — that mapping is yours to declare, which is guide 3.

⚠️

An unhandled exception reaches Spring Boot's default error handling and returns a generic 500 body from BasicErrorController. In production that body deliberately omits the message and stack trace (server.error.include-message defaults to never), which is right for security and unhelpful for clients. An API with no explicit error handling has no usable error contract — treat that as a missing feature, not a minor polish item.

The Thread Model, Briefly

On the servlet stack each request occupies one thread for its whole duration, including time blocked on a database or an outbound HTTP call. Tomcat's default ceiling is 200 threads (server.tomcat.threads.max), so sustained concurrency beyond that queues.

On Java 21+ you can hand these requests to virtual threads with one property:

yaml
spring:
  threads:
    virtual:
      enabled: true

Blocking calls then cost far less, because a blocked virtual thread doesn't hold an OS thread. This is the pragmatic answer to most "should we rewrite this reactive?" questions — WebFlux remains the right choice for streaming and very high connection counts, but virtual threads remove the scalability argument for a large class of ordinary blocking services.

💡

Virtual threads are not a free pass. A synchronized block around a blocking call can pin the carrier thread, and thread-pool-based rate limiting stops behaving as designed once threads are cheap. Also check that your connection pool is sized for the new concurrency — a thousand virtual threads contending for ten database connections has simply moved the bottleneck.

Check yourself

A @RestController method is declared to return String, and returns a hand-built JSON document as that string. The client reports the response is not valid JSON of the expected shape. Why?

The Mental Model, Restated

  1. DispatcherServlet is a front controller. Mapping → argument resolution → your method → return value handling → message conversion.
  2. 404, 405, 406 and 415 are decided at mapping time, before your code runs.
  3. Four binding annotations for four sources, and an unannotated complex parameter binds from query/form data — so always bind to a purpose-built DTO.
  4. ResponseEntity when you need status, headers or a Location; a plain object when 200-with-body is right.
  5. Message converters decide the bytes. Returning String gives text/plain, not JSON.

What's Next

Binding gets data into your method, but nothing so far has checked that the data is acceptable — a NewOrderRequest with a blank customer name and a quantity of −5 binds perfectly. The next guide covers Bean Validation: where @Valid fires and where it silently doesn't, the real difference between @Valid and @Validated, and writing a custom ConstraintValidator.