Spring MVC and REST controllers
How DispatcherServlet turns a request into a method call — mapping, argument binding, message conversion — and which status you get when nothing matches.
After this lesson you can
- Trace a request through DispatcherServlet to a controller method and back
- Predict which mapping wins when several patterns match a path
- Tell 404, 405, 415, 406 and 400 apart by what failed to match
Every request to a Spring MVC application enters one servlet, the
DispatcherServlet (a front controller). It asks its handler mappings
which controller method matches the request, invokes that method with
arguments resolved from the request, and writes the return value back
as the response body.
@RestController
@RequestMapping("/orders")
class OrderController {
@GetMapping("/{id}")
OrderDto get(@PathVariable long id) { ... }
@GetMapping
List<OrderDto> list(@RequestParam(defaultValue = "0") int page) { ... }
@PostMapping(consumes = "application/json")
ResponseEntity<OrderDto> create(@RequestBody @Valid NewOrder body) {
OrderDto saved = service.create(body);
return ResponseEntity.created(URI.create("/orders/" + saved.id())).body(saved);
}
}
@RestController is @Controller plus @ResponseBody: return values
are serialised into the body (by Jackson, through an
HttpMessageConverter) instead of being treated as view names.
@PathVariable binds a {segment} of the path, @RequestParam a query
parameter, and @RequestBody deserialises the body. Return a plain
object for 200 OK, or a ResponseEntity when you need to choose the
status and headers — 201 Created with a Location header for a POST
that creates something.
Which mapping wins
Several patterns can match one path: /orders/{id} and
/orders/export both match /orders/export. Spring does not take the
first declared mapping; it compares the matches and picks the most
specific. A literal segment is more specific than a {variable}, so
/orders/export wins regardless of the order the methods appear in.
import java.util.*; public class Solution { record Mapping(String method, String pattern, String handler) {} static Map<String, String> match(String pattern, String path) { String[] p = pattern.split("/", -1), s = path.split("/", -1); if (p.length != s.length) return null; Map<String, String> vars = new LinkedHashMap<>(); for (int i = 0; i < p.length; i++) { if (p[i].startsWith("{")) vars.put(p[i].substring(1, p[i].length() - 1), s[i]); else if (!p[i].equals(s[i])) return null; } return vars; } static String dispatch(List<Mapping> mappings, String method, String path) { Mapping best = null; Map<String, String> bestVars = null; Set<String> allowed = new TreeSet<>(); for (Mapping m : mappings) { Map<String, String> vars = match(m.pattern(), path); if (vars == null) continue; allowed.add(m.method()); if (!m.method().equals(method)) continue; if (best == null || vars.size() < bestVars.size()) { best = m; bestVars = vars; } } if (best != null) return method + " " + path + " -> 200 " + best.handler() + bestVars; if (!allowed.isEmpty()) return method + " " + path + " -> 405, Allow: " + String.join(", ", allowed); return method + " " + path + " -> 404"; } public static List<String> run() { List<Mapping> mappings = List.of( new Mapping("GET", "/orders/{id}", "getOrder"), // declared first new Mapping("GET", "/orders/export", "exportOrders"), new Mapping("POST", "/orders", "createOrder")); return List.of( dispatch(mappings, "GET", "/orders/42"), dispatch(mappings, "GET", "/orders/export"), dispatch(mappings, "DELETE", "/orders/42"), dispatch(mappings, "GET", "/orders"), dispatch(mappings, "GET", "/customers")); }}Since Spring Framework 6, trailing-slash matching is off by default:
/orders/ no longer matches a mapping for /orders.
When nothing matches, the status says why
- 404 Not Found — no mapping matches the path at all.
- 405 Method Not Allowed — the path matches, but not with this HTTP
method. The response carries an
Allowheader listing the methods that would work. - 415 Unsupported Media Type — the method matched but the request's
Content-Typeis not in the mapping'sconsumes. - 406 Not Acceptable — nothing the handler can produce satisfies the
request's
Acceptheader. - 400 Bad Request — a handler was found but its arguments could not
be bound: a required
@RequestParamis missing,/orders/abccannot become along, or the JSON body does not parse. Matching is textual —{id}matchesabc— and conversion happens only after the handler is chosen. Put a regex in the pattern,/orders/{id:\d+}, and/orders/abcno longer matches that mapping; with nothing else mapped there, it is a 404.
Telling these apart is a large part of debugging an API: a 405 means your path is right and your verb is wrong; a 404 means the path itself is not mapped (check the class-level prefix and the context path).
Exercise · Pro
404 or 400 for a bad id
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade