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.
Simplified, a request's path looks like this:
HTTP request
↓
[Tomcat — the embedded web server]
↓
[DispatcherServlet — the main entry point]
↓
finds the method responsible for this URL
↓
[calls your controller method]
↓
turns the result into JSON
↓
HTTP response
The good news: all of this is configured automatically. You don't need to register the DispatcherServlet by hand — Spring Boot does it at startup. You just write controllers, and the dispatcher finds the right method by URL on its own.
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:
@RestControllersays: 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 (5in/orders/5).@RequestParam— a parameter from the query string (status=NEWin/orders?status=NEW). You can set a default value.@RequestBody— the request body. Spring reads the JSON and assembles an object from it.
The most common thing beginners mix up: @PathVariable pulls a piece out of the path itself, while @RequestParam pulls what comes after the ?.
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.
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 all the endpoints 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
- Spring MVC removes the routine of handling HTTP: parsing the URL, reading parameters, converting JSON to an object and back.
DispatcherServletis the single entry point into the application: all requests go through it, and it finds the right method by URL. Configured automatically.@RestController+@GetMapping/@PostMapping/... — this is how a method becomes the handler for a specific address; the returned object is turned into JSON.- Data is pulled out of a request with three annotations:
@PathVariable(part of the path),@RequestParam(after the?),@RequestBody(the request body). - If status
200is enough — return the object directly; if you need a different status or headers — useResponseEntity. - Incoming data is validated with Bean Validation annotations on the fields plus
@Validon the parameter; without@Validthe rules won't fire. - Error handling is gathered in a single
@RestControllerAdvice; the unified response format isProblemDetail. - API documentation isn't written by hand — it's generated by springdoc-openapi (the
/swagger-ui.htmlpage).
What to read next
- Spring WebFlux — the reactive alternative and when it's actually needed.
@Transactionalin depth — how to make operations in a controller transactional the right way.- Spring Security — authentication and authorization inside MVC.
- Spring Testing —
@WebMvcTest,MockMvcand testing controllers.