@RestController and @RequestMapping
Turn a class into a JSON endpoint with @RestController, and map paths and HTTP methods with @RequestMapping and its shortcuts.
@RestController
@RequestMapping("/api/orders")
class OrderController {
@GetMapping("/{id}") OrderResponse get(@PathVariable long id) { ... }
@PostMapping OrderResponse create(@RequestBody CreateOrder body) { ... }
}A Spring MVC controller is a bean whose methods handle HTTP requests. @RestController marks the class as a controller and says that every return value is the response body. Spring picks a message converter from the return type and the request's Accept header, and for a record or a list of records that means JSON written by Jackson.
@RestController is @Controller plus @ResponseBody. Without the second half, Spring treats a returned String as a view name and goes looking for a template, which is the classic "why is it rendering an error page" mistake when someone copies a controller from a server-rendered app.
Mapping requests
@RequestMapping on the class sets a shared path prefix. On methods you normally use the shortcuts @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping; each is @RequestMapping with the method fixed. The paths join, so a class mapped to /api/orders with a method mapped to /{id} answers GET /api/orders/42.
@RestController
@RequestMapping("/api/orders")
class OrderController {
@GetMapping // GET /api/orders
@GetMapping("/{id}") // GET /api/orders/42
@PostMapping // POST /api/orders
}
A mapping can be narrowed further with params, headers, consumes (the request Content-Type) and produces (what the client accepts). Spring answers with the right status when nothing fits: 404 when no path matches, 405 with an Allow header when the path exists but the method does not, 415 when consumes rejects the body type and 406 when produces cannot satisfy Accept.
Mistakes worth avoiding
- A bare
@RequestMappingon a method with nomethodattribute matches every HTTP verb, so aDELETEruns your read code. Use the shortcuts. - Trailing slashes no longer match: since Spring Framework 6.0
/api/orders/is a different path from/api/ordersand returns 404. If old clients send the slash, add aUrlHandlerFilterrather than duplicating mappings. - Two methods with the same path and method fail at startup with an ambiguous mapping error. That is a feature: you find out before a request does.
- Keep controllers thin. Parse the request, call a service, shape the response. Business rules in controllers are hard to test and end up copied between endpoints.
Controllers are singletons shared by every request thread, so never store per-request data in fields.
Example
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
record OrderResponse(long id, String customer, BigDecimal total) {}
("/api/orders")
public class OrderController {
private final Map<Long, OrderResponse> orders = new ConcurrentHashMap<>(Map.of(
1L, new OrderResponse(1, "[email protected]", new BigDecimal("42.50")),
2L, new OrderResponse(2, "[email protected]", new BigDecimal("9.99"))));
// GET /api/orders -> a JSON array
public List<OrderResponse> list() {
return List.copyOf(orders.values());
}
// GET /api/orders/1 -> one JSON object, or 404
("/{id}")
public OrderResponse get( long id) {
OrderResponse order = orders.get(id);
if (order == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "order " + id + " not found");
}
return order;
}
}When to use it
- Expose GET /api/orders and GET /api/orders/{id} as JSON for a single-page front end.
- Group every endpoint for one resource under a shared /api/customers prefix so paths stay consistent.
- Stop a migrated client that sends /api/orders/ from getting 404s after moving to Spring Framework 6 or later.
More examples
What @RestController saves you
<code>@RestController</code> is a composed annotation made of exactly these two. Seeing the long form explains the most common bug in mixed applications: a class with only <code>@Controller</code> returns <code>"ok"</code>, and Spring tries to render a template called <code>ok</code>. In a JSON API, use <code>@RestController</code> everywhere and the question never comes up.
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
record Tag(String name) {}
// Equivalent to @RestController: @ResponseBody on the class applies to every method.
@Controller
@ResponseBody
@RequestMapping("/api/tags")
public class TagController {
@GetMapping
public List<Tag> list() {
return List.of(new Tag("java"), new Tag("spring"));
}
}Narrowing a mapping with consumes, produces and params
Attributes on the class are inherited by every method, and method-level <code>consumes</code> overrides the class value. Two <code>GET /api/notes</code> methods can coexist because <code>params = "pinned"</code> makes one more specific. Use this sparingly: a separate path is usually easier for clients to discover than a mapping that depends on which parameters happen to be present.
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
record Note(String text) {}
@RestController
@RequestMapping(path = "/api/notes", produces = MediaType.APPLICATION_JSON_VALUE)
public class NoteController {
// Only matches when the body is JSON; anything else gets 415 Unsupported Media Type.
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Note create(@RequestBody Note note) {
return note;
}
// GET /api/notes?pinned picks this method because the parameter is present.
@GetMapping(params = "pinned")
public List<Note> pinned() {
return List.of(new Note("Ship on Friday? No."));
}
@GetMapping
public List<Note> all() {
return List.of(new Note("Ship on Friday? No."), new Note("Buy milk"));
}
}Redirecting trailing slashes instead of duplicating mappings
Spring MVC stopped treating <code>/orders/</code> as <code>/orders</code> in Framework 6.0, so an old client or a hand-written link can suddenly start getting 404s after an upgrade. A filter bean is picked up by Spring Boot and runs before routing. Use <code>redirect</code> to teach clients the canonical URL, or <code>wrapRequest()</code> to strip the slash silently when you cannot change the clients.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.UrlHandlerFilter;
@Configuration
public class TrailingSlashConfig {
// GET /api/orders/ -> 308 redirect to /api/orders, keeping the method and body.
@Bean
UrlHandlerFilter trailingSlashFilter() {
return UrlHandlerFilter
.trailingSlashHandler("/api/**")
.redirect(HttpStatus.PERMANENT_REDIRECT)
.build();
}
}
Discussion