← Back to the section

When a browser or another service reaches your application over HTTP, someone has to accept that request, figure out which code is responsible for it, and return a response. That is exactly what Spring MVC does. Let's break it down from scratch: how a request travels through the application, how to write a controller for a REST API, how to validate the data you receive, and how to return errors cleanly.

Why you need Spring MVC at all

Imagine handling HTTP requests by hand. Then in every method you would have to parse the URL yourself, pull parameters out of the query string, read the body and turn JSON into an object, and then turn the object back into JSON. That's a lot of code, and it's the same everywhere.

Spring MVC takes this routine off your hands. You write an ordinary method and mark it with an annotation — "this method answers GET /orders/5". The framework does everything else (parsing the URL, reading the body, converting JSON) for you. MVC stands for Model-View-Controller, but for a REST API we mostly care about the Controller — a class with handler methods.

This is a synchronous web stack: each request is handled by its own thread from start to finish. There is also a reactive alternative — WebFlux — but it's needed less often than it seems, and plain MVC is enough for a typical backend service.

How a request reaches your method

In the past, without a single entry point, each part of the application decided for itself which requests it caught — and it turned into a mess. In Spring MVC there is one "dispatcher" that all requests pass through.

DispatcherServlet is the single entry point into the application (it's called the front controller, the "main controller"). Every HTTP request first lands there, and it then decides where to route it. An analogy: the reception desk in a large office — visitors don't go straight to the person they need, they go to the desk first, and it tells them which office to head to.

The request arrives at the embedded Tomcat, goes on to the DispatcherServlet, which picks a controller method, calls it and turns the result into JSON:

POST /orders · customerName = "Anna" POST /orders · customerName = "" DispatcherServlet finds the method by path and HTTP method matched: POST /orders → create(...) @Valid checks the fields before the method fields are fine customerName is empty create(...) ran 201 Created · JSON body @RestControllerAdvice 400 · ProblemDetail, method skipped

Every request goes through the DispatcherServlet: it picks a handler by path and HTTP method. Then @Valid checks the fields — a valid request reaches the controller method, a malformed one never does: @RestControllerAdvice builds the response instead.

The good news: you don't need to register the DispatcherServlet by hand — Spring Boot does it at startup.

A REST controller

A REST API is a set of addresses that a client sends requests to and receives data from (usually as JSON). To turn a method into such a handler, you put it in a class annotated with @RestController.

@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    public OrderResponse create(@RequestBody CreateOrderRequest request) {
        return orderService.create(request);
    }
}

What's happening here:

  • @RestController says: this is a controller, and whatever its methods return should be sent as the response body (JSON), not treated as a request for an HTML page.
  • @RequestMapping("/orders") on the class is a shared address prefix. Every method inside will start with /orders.
  • @GetMapping, @PostMapping — which HTTP method and path the method answers. There are also @PutMapping, @DeleteMapping, @PatchMapping — one for each kind of request.

The get method answers GET /orders/5, the create method answers POST /orders. Spring turns the returned object into JSON for you.

How to pull data out of a request

Data arrives in a request in three different ways, and each has its own annotation.

// /orders/5 — part of the address itself
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) { ... }

// /orders?status=NEW&limit=20 — parameters after the ? sign
@GetMapping
public List<OrderResponse> list(
        @RequestParam String status,
        @RequestParam(defaultValue = "10") int limit) { ... }

// the body of a POST request (JSON) is turned into an object
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) { ... }
  • @PathVariable — a value straight from the address (5 in /orders/5).
  • @RequestParam — a parameter from the query string (status=NEW in /orders?status=NEW). You can set a default value.
  • @RequestBody — the request body. Spring reads the JSON and assembles an object from it.

These two are easy to mix up: @PathVariable pulls a piece out of the path itself, while @RequestParam pulls what comes after the ?.

The same mechanics are visible without Spring: the path is cut off from the query string, a handler is looked up by it, and the values are taken out of the path and the parameters.

live example

import java.util.HashMap;
import java.util.Map;

public class MiniDispatcher {

    static String dispatch(String method, String uri) {
        String path = uri.split("\\?", 2)[0];
        Map<String, String> params = new HashMap<>();
        if (uri.contains("?")) {
            for (String pair : uri.split("\\?", 2)[1].split("&")) {
                String[] kv = pair.split("=", 2);
                params.put(kv[0], kv[1]);
            }
        }
        String[] parts = path.split("/");
        if (method.equals("GET") && parts.length == 3 && parts[1].equals("orders")) {
            return "get(id=" + parts[2] + ")";
        }
        if (method.equals("GET") && path.equals("/orders")) {
            return "list(status=" + params.getOrDefault("status", "ANY")
                    + ", limit=" + params.getOrDefault("limit", "10") + ")";
        }
        if (parts.length == 3 && parts[1].equals("orders")) {
            return "405 Method Not Allowed";
        }
        return "404 Not Found";
    }

    public static void main(String[] args) {
        System.out.println(dispatch("GET", "/orders/5"));
        System.out.println(dispatch("GET", "/orders?status=NEW&limit=20"));
        System.out.println(dispatch("GET", "/orders"));
        System.out.println(dispatch("DELETE", "/orders/5"));
        System.out.println(dispatch("GET", "/invoices/5"));
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

get(id=5)
list(status=NEW, limit=20)
list(status=ANY, limit=10)
405 Method Not Allowed
404 Not Found

The third line is that same default value from @RequestParam(defaultValue = "10"). The fourth is the same path with a different method: Spring answers 405, not 404. The fifth is an address that does not exist at all — that is where 404 comes from.

What to return from a method

Most of the time it's enough to return a plain object — Spring turns it into JSON and sends it with status 200 OK. But sometimes you need to control the response status or headers: for example, after creating a record it's customary to return 201 Created. That's what ResponseEntity is for.

@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
    OrderResponse created = orderService.create(request);
    return ResponseEntity
        .status(HttpStatus.CREATED)        // 201 instead of 200
        .body(created);
}

The rule is simple: if status 200 is enough — return the object directly, it's shorter. If you need a different status or headers — wrap it in ResponseEntity.

Validating incoming data

A client can send anything: an empty name, a negative quantity, a date from the past. If you don't check, malformed data will travel deep into the application and break something in a place where it's already hard to figure out. It's better to validate right at the entrance.

For this there is Bean Validation — a standard mechanism where rules are defined right on the fields with annotations. In Spring Boot it's added via the spring-boot-starter-validation dependency.

public record CreateOrderRequest(
    @NotBlank String customerName,            // a non-empty string
    @NotEmpty List<OrderLineRequest> lines,   // the list is not empty
    @NotNull @Future LocalDateTime deliveryDate  // a date in the future
) {}

The most common annotations: @NotNull (not null), @NotBlank (the string is not empty), @NotEmpty (the collection is not empty), @Size (length within a range), @Min/@Max and @Positive (numeric constraints), @Email, @Future/@Past (dates).

For Spring to actually apply these rules, you put @Valid on the parameter:

@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }

Without @Valid the annotations on the fields do nothing — this is a common reason for "why didn't the validation fire". If the data fails the check, Spring rejects the request itself, before entering the method body.

Handling errors in one place

Something always goes wrong: the order isn't found, the data fails validation, the database goes down. If you write try/catch in every method and assemble the error response by hand, the code bloats and errors look different in different places. It's better to gather all handling into a single class.

For this there is @RestControllerAdvice — a class that catches exceptions from all controllers at once. Inside are methods annotated with @ExceptionHandler, one per error type.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<String> handleNotFound(OrderNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleUnknown(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body("Something went wrong");
    }
}

Now, wherever an OrderNotFoundException is thrown in a controller, the client gets a clean 404 response. And the last method is a "catch-all for everything else": any unexpected error turns into a 500 rather than a mess of a stack trace.

That catch-all has a downside. A handler for Exception also intercepts MethodArgumentNotValidException — the very exception @Valid throws. As a result a request with an empty name returns 500 instead of 400, and the client concludes the server is broken. That's why a separate handler for MethodArgumentNotValidException is always kept next to it — it takes validation errors before the catch-all reaches them.

A single error format

So that errors from the whole service look the same, Spring provides a ready-made response format — ProblemDetail. It's simply an object with clear fields, which is also returned as JSON.

@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

The response will look like this:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 5 not found"
}

Don't invent your own error format — when all responses have the same structure, it's easier for clients (web, mobile app) to parse them.

API documentation via OpenAPI

When there are many controllers, clients need clear reference material: which addresses exist, what they accept and return. Writing it by hand and keeping it up to date is hard — it quickly drifts away from the code.

That's why documentation is generated automatically. The springdoc-openapi library is added with a single dependency and builds the API description from your controllers itself.

dependencies {
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.x.x")
}

After that, the address /swagger-ui.html opens a ready-made page where you can see every address and even poke at them right from the browser. Since the reference is built from the code, it always matches what's actually there.

In short

  • DispatcherServlet is the single entry point into the application: all requests go through it, and it finds the method by path and HTTP method. You never register it by hand.
  • @RestController + @GetMapping/@PostMapping/... turn a method into the handler for a specific address; the returned object goes out as JSON, and when you need a different status or headers there is ResponseEntity.
  • Data is pulled out of a request with three annotations: @PathVariable (part of the path), @RequestParam (after the ?), @RequestBody (the request body).
  • Bean Validation annotations on the fields only fire together with @Valid on the parameter — without it the rules stay silent.
  • Error handling is gathered in a single @RestControllerAdvice with ProblemDetail as the shared response format; don't let the Exception catch-all swallow the 400 from validation.
  • API reference material isn't written by hand — springdoc-openapi generates it, on the /swagger-ui.html page.