The annotation that builds your API
Almost every Spring Boot service is, at its surface, a stack of REST controllers. Get them right and the rest of the app has a clean, predictable boundary; get them wrong and you leak entities, return the wrong status codes, and confuse every client. This article walks through how a request becomes a method call and how to shape that method well.
@RestController is @Controller + @ResponseBody
@RestController marks a class whose methods return data written straight to the response body
rather than the name of a view to render.
@RestController
@RequestMapping("/api/orders")
class OrderController {
@GetMapping("/{id}")
Order byId(@PathVariable Long id) {
return service.find(id); // serialized to JSON by Jackson — no view lookup
}
}
With a plain @Controller, a returned String is a view name for a template engine.
@RestController flips that: the return value is the payload. It's the default for APIs.
The mapping shortcuts
@RequestMapping is the general annotation; @GetMapping, @PostMapping, @PutMapping,
@PatchMapping, and @DeleteMapping are composed shortcuts that pin the HTTP method.
@RequestMapping(value = "/orders", method = RequestMethod.GET) // verbose
@GetMapping("/orders") // identical, idiomatic
Put a class-level @RequestMapping("/api/orders") on the controller for the base path, then let
each method-level shortcut append to it. The verb is obvious at a glance.
Pulling data out of the request
Three annotations cover almost everything:
@GetMapping("/users/{userId}/orders")
List<Order> list(
@PathVariable Long userId, // {placeholder} in the path
@RequestParam(defaultValue = "0") int page, // ?page= query string
@RequestHeader(value = "X-Tenant", required = false) String tenant) { // a header
return service.list(userId, page, tenant);
}
@PostMapping
Order create(@Valid @RequestBody CreateOrderRequest body) { // JSON body → object
return service.create(body);
}
The split between @PathVariable and @RequestParam matters: the path identifies the resource,
the query string tunes how you want it. /products/42 names a product; /products?category=books
filters a collection. Optional, combinable values belong in the query string; mandatory identifiers
belong in the path.
Only one @RequestBody per method — the body is a stream you read once — and pair it with @Valid
so Bean Validation runs before your logic does.
ResponseEntity when you need full control
Returning a plain object always yields 200 (or whatever @ResponseStatus declares). When the
status or headers depend on the outcome, return a ResponseEntity.
@PostMapping
ResponseEntity<Order> create(@RequestBody CreateOrderRequest req) {
Order saved = service.create(req);
return ResponseEntity
.created(URI.create("/api/orders/" + saved.id())) // 201 + Location header
.body(saved);
}
@GetMapping("/{id}")
ResponseEntity<Order> find(@PathVariable Long id) {
return service.findOptional(id)
.map(ResponseEntity::ok) // 200 + body
.orElse(ResponseEntity.notFound().build()); // 404, no body
}
For a fixed status, @ResponseStatus(HttpStatus.CREATED) on the method is cleaner. Reach for
ResponseEntity only when the code is computed.
How a request actually gets routed
Every request enters through the DispatcherServlet, Spring MVC's front controller:
request → DispatcherServlet
→ HandlerMapping (which @RequestMapping method matches?)
→ HandlerAdapter (invoke it, resolve @PathVariable/@RequestBody args)
→ your controller method
→ HttpMessageConverter (serialize the return value → JSON)
→ response
A HandlerMapping finds the matching method, a HandlerAdapter invokes it (binding arguments via
HandlerMethodArgumentResolvers), and an HttpMessageConverter renders the return value. Spring
Boot auto-registers the DispatcherServlet at /, so you never wire it up.
Content negotiation
Which format you get back is driven by the request's Accept header and the converters on the
classpath. JSON works out of the box because jackson-databind is present; XML needs
jackson-dataformat-xml. The produces attribute restricts what a handler emits and consumes
restricts what it accepts — a mismatch returns 406 or 415.
Controllers are stateless singletons
A controller is a singleton bean shared by every concurrent request, so it must be stateless. Per-request data arrives as method parameters, never instance fields:
@RestController
class CounterController {
private int hits; // BUG: shared mutable state — race condition
@GetMapping("/hit") int hit() { return ++hits; }
}
Keep request state in parameters and locals, and your injected services (also stateless singletons) keep the whole stack thread-safe.
Conventions for a clean API
@RestController
@RequestMapping("/api/v1/orders") // versioned, plural noun
class OrderController {
@GetMapping List<Order> list() { ... } // 200
@GetMapping("/{id}") Order get(@PathVariable Long id) { } // 200 / 404
@PostMapping @ResponseStatus(CREATED) Order create(...) { } // 201
@DeleteMapping("/{id}") @ResponseStatus(NO_CONTENT) void delete(...) { } // 204
}
Plural nouns in the path, verbs out of the URL (/orders, not /getOrder), a version prefix, the
right HTTP method per action, and DTOs at the boundary instead of raw JPA entities.
Recap
@RestController writes return values straight to the body; the mapping shortcuts pin the verb;
@PathVariable/@RequestParam/@RequestBody/@RequestHeader bind the request; ResponseEntity
and @ResponseStatus shape the response; the DispatcherServlet ties it together; and content
negotiation picks the format. Keep controllers stateless, model resources as nouns, use HTTP verbs
for actions, and map to DTOs — that's a REST controller an interviewer (and a client) will respect.