A Spring MVC REST request moves through routing, argument binding, validation, controller coordination, application services, and HTTP message conversion. @RestController makes return values response bodies, while mapping annotations connect HTTP methods and paths to handler methods.
A production-quality API is more than a controller that returns JSON. It needs stable transport DTOs, correct method and status semantics, consistent error documents, bounded collections, authorization at the resource boundary, and tests that verify the HTTP contract without coupling clients to implementation details.
Name routes after resources and use HTTP methods to express the operation. GET /api/orders reads a collection, POST creates within it, GET /api/orders/{id} reads one resource, PUT replaces state, PATCH applies a partial change, and DELETE removes it. Avoid action-heavy paths when standard semantics already describe the intent.
Mapping annotations can constrain path, method, consumed media type, and produced media type. Bind path identifiers with @PathVariable, query controls with @RequestParam, and JSON payloads with @RequestBody. Keep transport input separate from persistence entities.
package com.example.controller;
import com.example.entity.User;
import com.example.service.UserService;
import org.springframework.data.domain.*;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.net.URI;
@RestController
@RequestMapping("/api/v1/users")
@CrossOrigin(origins = "http://localhost:3000") // Allow CORS from React app
public class UserRestController {
private final UserService userService;
public UserRestController(UserService userService) {
this.userService = userService;
}
// GET /api/v1/users?page=0&size=10&sort=username
@GetMapping
public ResponseEntity<Page<User>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "id") String sort) {
Page<User> users = userService.getUsers(page, size, sort);
return ResponseEntity.ok(users);
}
// GET /api/v1/users/42
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
// POST /api/v1/users
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
User created = userService.createUser(user);
URI location = URI.create("/api/v1/users/" + created.getId());
return ResponseEntity.created(location).body(created); // 201 Created
}
// PUT /api/v1/users/42
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id,
@Valid @RequestBody User user) {
User updated = userService.updateUser(id, user);
return ResponseEntity.ok(updated);
}
// DELETE /api/v1/users/42
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
Use request DTOs to declare the fields a client may send. Bean Validation annotations describe structural rules such as required text, length, ranges, and valid email shape. @Valid triggers validation during binding, before business logic runs.
Validation cannot decide every business rule. Availability, ownership, state transitions, and uniqueness require services and database constraints. Treat client validation as useful feedback, server validation as mandatory, and database constraints as the final integrity boundary.
public record CreateOrderRequest(
@jakarta.validation.constraints.NotBlank String customerReference,
@jakarta.validation.constraints.Positive int quantity
) {}
@PostMapping
ResponseEntity<OrderResponse> create(
@jakarta.validation.Valid @RequestBody CreateOrderRequest request) {
OrderResponse created = service.create(request);
URI location = URI.create("/api/orders/" + created.id());
return ResponseEntity.created(location).body(created);
}
A successful creation returns 201 and a Location header that identifies the new order. Invalid structural input is rejected before service.create() runs.
Return a body object directly when 200 with normal negotiation is sufficient. Use ResponseEntity when the handler must control status or headers. Creation commonly returns 201 with Location, deletion may return 204, a missing resource returns 404, incompatible state can return 409, and malformed input returns 400.
Keep response DTO fields deliberate and backwards-compatible. Avoid leaking stack traces, entity internals, lazy relationships, security decisions, or database column names. Dates, money, identifiers, and enums need explicit JSON contracts.
| Outcome | Status | Body expectation |
|---|---|---|
| Representation returned | 200 OK | Resource or collection DTO |
| Resource created | 201 Created | Resource DTO and Location |
| Success without content | 204 No Content | No body |
| Invalid request shape | 400 Bad Request | Stable validation details |
| Resource absent | 404 Not Found | Stable problem document |
| State conflict | 409 Conflict | Conflict explanation safe for client |
Centralize exception-to-response mapping with @RestControllerAdvice so controllers focus on successful coordination. Map known domain failures deliberately and let unexpected exceptions become a generic 500 response while detailed diagnostics stay in server logs.
Spring supports ProblemDetail for structured HTTP error responses. Keep type, title, status, detail, instance, and any extension fields stable enough for clients. Do not expose internal exception text automatically.
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.*;
@RestControllerAdvice // Handles exceptions for all @RestController classes
public class GlobalExceptionHandler {
// Handle resource not found
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(404, ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Handle validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String field = ((FieldError) error).getField();
String message = error.getDefaultMessage();
errors.put(field, message);
});
return ResponseEntity.badRequest().body(errors);
}
// Handle all other exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
ErrorResponse error = new ErrorResponse(500, "Internal server error");
return ResponseEntity.internalServerError().body(error);
}
record ErrorResponse(int status, String message) {}
}
Collection endpoints need bounded pagination, deterministic sorting, and an allowlist of filter and sort fields. Returning every row makes latency and memory usage grow with the database. Include navigation or page metadata that matches the client workflow.
CORS is a browser access-control mechanism, not authentication. Configure exact trusted origins, methods, headers, and credential behavior. A wildcard origin cannot be combined safely with credentialed browser requests. Authorization must still run for every protected request.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "https://myapp.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600); // Cache preflight for 1 hour
}
}
// Or use @CrossOrigin on individual controllers/methods:
// @CrossOrigin(origins = "*", maxAge = 3600)
// @CrossOrigin(origins = {"http://localhost:3000", "https://myapp.com"})
A focused MVC test should verify route, status, content type, important JSON fields, validation errors, and exception mapping. Service behavior can be mocked in a controller slice, while broader integration tests prove serialization, security, persistence, and configuration together.
@Test
void rejectsMissingCustomerReference() throws Exception {
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"customerReference\":\"\",\"quantity\":2}"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON));
}
Try this next
0 of 3 completed
Use it when the handler needs explicit status or headers; a plain DTO is enough for an ordinary 200 response body.
For nontrivial applications, use a service boundary so business rules and transactions remain independent of HTTP.
No. It validates the bound object; concurrent writes and final integrity still require service rules and database constraints.
Explore 500+ free tutorials across 20+ languages and frameworks.