03-spring-boot-essentials

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.

August 14, 2026
backend-engineerrestcontrollershttpspring-mvcapi-design

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

java
@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.

java
@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

VerbIdempotent?Safe?Typical useSuccess status
GETYesYesFetch a resource or collection200 OK
POSTNoNoCreate a new resource201 Created
PUTYesNoReplace a resource entirely200 OK or 204 No Content
PATCHNo (usually)NoPartially update a resource200 OK
DELETEYesNoRemove a resource204 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

java
// 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

AnnotationSourceExample
@PathVariableURI template segment/orders/{id}String id
@RequestParamQuery string?status=SHIPPEDOrderStatus status
@RequestBodyHTTP body, deserializedJSON → CreateOrderRequest
@RequestHeaderHTTP headerX-Correlation-IdString correlationId
@CookieValueCookiesession-id cookie → String sessionId
@ModelAttributeForm fields / query params bound to an objectRare 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.

java
@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

CodeMeaningWhen to return it
200 OKSuccess with bodyGET, successful PUT/PATCH
201 CreatedResource createdPOST that creates a new resource — include Location header
204 No ContentSuccess, no bodyDELETE, or PUT where you don't return the resource
400 Bad RequestMalformed request / validation failureBean Validation failures, bad JSON
401 UnauthorizedMissing/invalid authenticationNo token, expired token
403 ForbiddenAuthenticated but not permittedRole/permission check failed
404 Not FoundResource doesn't existUnknown orderId
409 ConflictState conflictDuplicate creation, optimistic-locking version mismatch
422 Unprocessable EntitySemantically invalid, syntactically validBusiness rule violation (rare; many APIs use 400 for this too)
429 Too Many RequestsRate limit exceededThrottling
500 Internal Server ErrorUnhandled exceptionBugs, 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.

java
// 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.

java
@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

java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController { /* ... */ }

REST resource naming conventions

PatternGoodAvoid
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

java
@Configuration
public class WebConfig implements WebMvcConfigurer {
 
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.addPathPrefix("/api/v1", HandlerTypePredicate.forBasePackage("com.acme.orders.web"));
    }
}
yaml
# application.yml — springdoc-openapi auto-generates docs from your controllers
springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
java
@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.

java
@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:

java
@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

PitfallConsequenceFix
Returning JPA entities directly from controllersLeaks lazy-loading proxies, exposes internal schema, breaks under LazyInitializationExceptionAlways map to a dedicated response DTO
No pagination on list endpointsUnbounded queries, OOM under data growthDefault page/size params, cap max page size
Business logic inside the controllerUntestable without HTTP, violates separation of concernsController stays thin — delegate to a @Service
Swallowing exceptions with a generic try/catch in the controllerHides real errors, inconsistent error shapeUse @ControllerAdvice (see the validation & exception-handling guide)
Mutable shared state in a @RestController fieldData races across concurrent requestsControllers 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

  • DispatcherServlet is the single front controller; HandlerMapping and HandlerAdapter do 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 @GetMapping with a bare object.
  • Match HTTP verbs to their idempotency contract: GET/PUT/DELETE must be safely retryable; POST/PATCH are not guaranteed to be.
  • 201 Created should always carry a Location header pointing at the new resource.
  • Never return a 200 status 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 @Controller and @RestController?
  • Why should PUT and DELETE be idempotent? What breaks if they aren't?
  • When would you use @PathVariable versus @RequestParam?
  • What does ResponseEntity give 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 POST that creates a resource return, and what header should accompany it?
  • How would you design pagination for a GET /orders endpoint that could return millions of rows?
  • What's the difference between PUT and PATCH semantically?
  • 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 @RestController method?
  • What happens if you call a blocking JDBC repository method inside a CompletableFuture.supplyAsync() — does it actually make the request non-blocking?