REST APIs and Controllers in Spring Boot: Production Patterns
A staff-engineer guide to building REST APIs with Spring MVC — routing, request binding, ResponseEntity, status codes, and content negotiation.
REST APIs and Controllers
Spring MVC's controller layer is where HTTP meets your domain model. This guide covers building CRUD endpoints correctly — not just the annotations that make requests route, but the production-grade decisions around status codes, response shape, and API contracts that separate a working demo from a maintainable service.
1. Anatomy of a Spring MVC Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
Order order = orderService.findById(orderId);
return ResponseEntity.ok(OrderResponse.from(order));
}
}@RestController is @Controller + @ResponseBody combined — every method's return value is serialized directly into the HTTP response body (typically as JSON via Jackson), rather than resolved as a view name.
DispatcherServlet is the single entry point for every request in a Spring MVC application. It doesn't handle business logic itself — it delegates to HandlerMapping to find the right controller method, and HandlerAdapter to invoke it with correctly bound arguments. Understanding this separation helps when debugging routing issues: "no handler found" is a HandlerMapping problem, not a controller bug.
2. Mapping HTTP Methods to CRUD Operations
A well-designed REST resource maps HTTP verbs onto operations consistently.
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public ResponseEntity<List<OrderResponse>> listOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Order> orders = orderService.findAll(PageRequest.of(page, size));
return ResponseEntity.ok(orders.map(OrderResponse::from).getContent());
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
Order order = orderService.findById(orderId);
return ResponseEntity.ok(OrderResponse.from(order));
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request,
UriComponentsBuilder uriBuilder) {
Order created = orderService.create(request.toCommand());
URI location = uriBuilder.path("/api/v1/orders/{id}").buildAndExpand(created.getId()).toUri();
return ResponseEntity.created(location).body(OrderResponse.from(created));
}
@PutMapping("/{orderId}")
public ResponseEntity<OrderResponse> replaceOrder(@PathVariable String orderId,
@Valid @RequestBody UpdateOrderRequest request) {
Order updated = orderService.replace(orderId, request.toCommand());
return ResponseEntity.ok(OrderResponse.from(updated));
}
@PatchMapping("/{orderId}")
public ResponseEntity<OrderResponse> patchOrder(@PathVariable String orderId,
@RequestBody Map<String, Object> updates) {
Order patched = orderService.applyPartialUpdate(orderId, updates);
return ResponseEntity.ok(OrderResponse.from(patched));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(@PathVariable String orderId) {
orderService.cancel(orderId);
return ResponseEntity.noContent().build();
}
}HTTP verbs and their contract
| Verb | Idempotent? | Safe? | Typical use | Success status |
|---|---|---|---|---|
GET | Yes | Yes | Fetch a resource or collection | 200 OK |
POST | No | No | Create a new resource | 201 Created |
PUT | Yes | No | Replace a resource entirely | 200 OK or 204 No Content |
PATCH | No (usually) | No | Partially update a resource | 200 OK |
DELETE | Yes | No | Remove a resource | 204 No Content |
Idempotency is a contract, not a suggestion. PUT and DELETE must produce the same server state no matter how many times a client retries them (crucial for clients behind flaky networks that retry on timeout). If your PUT handler increments a counter or appends to a list instead of replacing state, you've broken the HTTP contract and will cause subtle bugs under retry-heavy load balancers.
3. Path Variables, Request Parameters, and Request Bodies
// Path variable — identifies a specific resource
@GetMapping("/{orderId}/items/{itemId}")
public ResponseEntity<LineItemResponse> getLineItem(
@PathVariable String orderId,
@PathVariable String itemId) {
// ...
}
// Request parameters — filtering, pagination, sorting
@GetMapping("/search")
public ResponseEntity<List<OrderResponse>> search(
@RequestParam(required = false) String customerId,
@RequestParam(required = false) OrderStatus status,
@RequestParam(defaultValue = "createdAt,desc") String sort) {
// ...
}
// Request headers
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(
@PathVariable String orderId,
@RequestHeader("X-Correlation-Id") String correlationId) {
// ...
}
// Request body — deserialized by Jackson via HttpMessageConverter
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<LineItemRequest> items,
@Valid ShippingAddressRequest shippingAddress) {
OrderCommand toCommand() {
return new OrderCommand(customerId, items, shippingAddress);
}
}Use Java records for request/response DTOs. They're immutable, concise, and Jackson deserializes into them via the canonical constructor with zero extra configuration since Jackson 2.12+. There's rarely a reason to write a mutable POJO with getters/setters for a DTO in a modern Spring Boot 3.x codebase.
Binding table
| Annotation | Source | Example |
|---|---|---|
@PathVariable | URI template segment | /orders/{id} → String id |
@RequestParam | Query string | ?status=SHIPPED → OrderStatus status |
@RequestBody | HTTP body, deserialized | JSON → CreateOrderRequest |
@RequestHeader | HTTP header | X-Correlation-Id → String correlationId |
@CookieValue | Cookie | session-id cookie → String sessionId |
@ModelAttribute | Form fields / query params bound to an object | Rare in REST APIs; common in form-based MVC |
4. ResponseEntity and Status Code Discipline
ResponseEntity<T> gives full control over status code, headers, and body — this is the standard return type for any endpoint that needs more than a bare 200.
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
return orderService.findByIdOptional(orderId)
.map(order -> ResponseEntity.ok(OrderResponse.from(order)))
.orElseGet(() -> ResponseEntity.notFound().build());
}Status codes that matter in a well-designed API
| Code | Meaning | When to return it |
|---|---|---|
200 OK | Success with body | GET, successful PUT/PATCH |
201 Created | Resource created | POST that creates a new resource — include Location header |
204 No Content | Success, no body | DELETE, or PUT where you don't return the resource |
400 Bad Request | Malformed request / validation failure | Bean Validation failures, bad JSON |
401 Unauthorized | Missing/invalid authentication | No token, expired token |
403 Forbidden | Authenticated but not permitted | Role/permission check failed |
404 Not Found | Resource doesn't exist | Unknown orderId |
409 Conflict | State conflict | Duplicate creation, optimistic-locking version mismatch |
422 Unprocessable Entity | Semantically invalid, syntactically valid | Business rule violation (rare; many APIs use 400 for this too) |
429 Too Many Requests | Rate limit exceeded | Throttling |
500 Internal Server Error | Unhandled exception | Bugs, unexpected failures — never intentional |
Never return 200 OK with an error payload. A response body like {"error": "order not found"} accompanied by status 200 breaks every HTTP-aware tool — caching proxies, monitoring dashboards, retry logic, API gateways — because they all key off the status code, not the body. Always let the status code tell the truth.
// 201 Created with Location header — the correct pattern for resource creation
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request,
UriComponentsBuilder uriBuilder) {
Order created = orderService.create(request.toCommand());
URI location = uriBuilder.path("/api/v1/orders/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(OrderResponse.from(created));
}5. Content Negotiation
Spring MVC decides how to serialize a response based on the Accept header, using registered HttpMessageConverters.
@GetMapping(value = "/{orderId}", produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
// Same method serves JSON or XML depending on the client's Accept header
}In practice, most modern REST APIs standardize on JSON only and drop XML support entirely — it simplifies the HttpMessageConverter chain and avoids XXE-class vulnerabilities associated with XML parsing. Unless you have a specific enterprise integration requirement, produces = MediaType.APPLICATION_JSON_VALUE is enough.
6. Path Design and Versioning
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController { /* ... */ }REST resource naming conventions
| Pattern | Good | Avoid |
|---|---|---|
| Collection | /orders | /getOrders, /order-list |
| Single resource | /orders/{id} | /orders/get/{id} |
| Nested resource | /orders/{id}/items | /order-items?orderId={id} (when clearly owned) |
| Action that isn't CRUD | /orders/{id}/cancel (POST) | PUT /orders/{id} with a status field change disguised as a full update |
| Plural nouns | /customers, /orders | /customer, /Order |
API versioning strategies — URI versioning (/api/v1/...) is the simplest and most cache-friendly; header versioning (Accept: application/vnd.acme.v2+json) is more "RESTfully pure" but harder to test with a browser and harder to route at a gateway/CDN layer. Most production teams pick URI versioning for its operational simplicity, reserving header versioning for APIs with a formal external partner ecosystem.
7. Global Base Path and API Documentation
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api/v1", HandlerTypePredicate.forBasePackage("com.acme.orders.web"));
}
}# application.yml — springdoc-openapi auto-generates docs from your controllers
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management operations")
public class OrderController {
@Operation(summary = "Get an order by ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Order found"),
@ApiResponse(responseCode = "404", description = "Order not found")
})
@GetMapping("/{orderId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
// ...
return null;
}
}8. Async and Reactive Considerations (Servlet Stack)
Even on the traditional Servlet stack (spring-boot-starter-web, not WebFlux), you can offload long-running work without blocking a request thread.
@GetMapping("/{orderId}/report")
public CompletableFuture<ResponseEntity<ReportResponse>> generateReport(@PathVariable String orderId) {
return reportService.generateAsync(orderId)
.thenApply(report -> ResponseEntity.ok(ReportResponse.from(report)));
}Returning CompletableFuture<ResponseEntity<T>> frees the Servlet container thread while the async work runs on another executor, improving throughput under I/O-bound load. But this only helps if the async work is genuinely non-blocking (e.g., a non-blocking HTTP client) — wrapping a blocking JDBC call in CompletableFuture.supplyAsync() just moves the blocking to a different thread pool; it doesn't remove it. For truly non-blocking end-to-end pipelines, consider WebFlux — a separate architectural choice outside the scope of this guide.
9. Testing the Controller Layer (Preview)
Controllers are tested in isolation using @WebMvcTest, covered in depth in the testing guide — but the shape is worth previewing here since it validates the contract you just designed:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void getOrder_returns200WithBody() throws Exception {
given(orderService.findById("order-1")).willReturn(sampleOrder());
mockMvc.perform(get("/api/v1/orders/order-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("order-1"));
}
}10. Common Production Pitfalls
| Pitfall | Consequence | Fix |
|---|---|---|
| Returning JPA entities directly from controllers | Leaks lazy-loading proxies, exposes internal schema, breaks under LazyInitializationException | Always map to a dedicated response DTO |
| No pagination on list endpoints | Unbounded queries, OOM under data growth | Default page/size params, cap max page size |
| Business logic inside the controller | Untestable without HTTP, violates separation of concerns | Controller stays thin — delegate to a @Service |
Swallowing exceptions with a generic try/catch in the controller | Hides real errors, inconsistent error shape | Use @ControllerAdvice (see the validation & exception-handling guide) |
Mutable shared state in a @RestController field | Data races across concurrent requests | Controllers are singletons — keep them stateless |
Never expose a JPA @Entity as a controller return type. Jackson will try to serialize lazy-loaded associations outside the transaction boundary, throwing LazyInitializationException, or — worse — it will serialize them successfully and leak your entire object graph (including fields you never meant to expose, like password hashes on a User entity) straight into the HTTP response.
Key takeaways
DispatcherServletis the single front controller;HandlerMappingandHandlerAdapterdo the routing and invocation work behind every@RequestMapping.- Use
ResponseEntity<T>whenever you need precise control over status code, headers, or conditional responses — it's the standard return type for real endpoints, not just@GetMappingwith a bare object. - Match HTTP verbs to their idempotency contract:
GET/PUT/DELETEmust be safely retryable;POST/PATCHare not guaranteed to be. 201 Createdshould always carry aLocationheader pointing at the new resource.- Never return a
200status with an error body, and never serialize a JPA entity directly — both break tooling and leak internals respectively. - Records make excellent, concise request/response DTOs and work natively with Jackson in Spring Boot 3.x.
- Keep controllers thin: parse input, delegate to a service, shape the response. Business logic belongs in the service layer.
- Pick a versioning strategy (URI is simplest) before your first external consumer, not after.
Interview Questions
- Walk through what happens between a client sending an HTTP request and a Spring MVC controller method returning a response.
- What is the difference between
@Controllerand@RestController? - Why should
PUTandDELETEbe idempotent? What breaks if they aren't? - When would you use
@PathVariableversus@RequestParam? - What does
ResponseEntitygive you that a bare return type doesn't? - Why is it a bad practice to return a JPA entity directly from a REST controller?
- How does Spring MVC decide whether to serialize a response as JSON or XML?
- What status code should a successful
POSTthat creates a resource return, and what header should accompany it? - How would you design pagination for a
GET /ordersendpoint that could return millions of rows? - What's the difference between
PUTandPATCHsemantically? - How would you version a public REST API, and what are the trade-offs between URI versioning and header versioning?
- Why shouldn't business logic live inside a
@RestControllermethod? - What happens if you call a blocking JDBC repository method inside a
CompletableFuture.supplyAsync()— does it actually make the request non-blocking?