Building REST APIs with Spring Boot
Build production-quality REST APIs with Spring Boot — request mapping, path variables, query params, request bodies, response entities, and HTTP status codes.
Spring MVC (included in spring-boot-starter-web) handles HTTP routing, JSON serialisation, and response building. It maps incoming HTTP requests to Java methods, converts JSON to objects and back, and lets you express the full HTTP contract — status codes, headers, response bodies — in plain Java. This guide shows the patterns used in production REST APIs.
Controller Basics
A controller class groups related endpoints under one URL prefix. @RestController tells Spring to serialise return values as JSON. Each method is annotated with the HTTP method it handles (@GetMapping, @PostMapping, etc.) and the path relative to the class-level @RequestMapping.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// Constructor injection — Spring provides the UserService bean
public UserController(UserService userService) {
this.userService = userService;
}
// GET /api/users — returns all users as a JSON array
@GetMapping
public List<UserResponse> getAllUsers() {
return userService.getAllUsers();
}
// GET /api/users/42 — Spring extracts 42 from the path
@GetMapping("/{id}")
public UserResponse getUserById(@PathVariable Long id) {
return userService.getUserById(id);
}
// POST /api/users — @Valid triggers Bean Validation before the method runs
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // sends 201 instead of the default 200
public UserResponse createUser(@RequestBody @Valid CreateUserRequest request) {
return userService.createUser(request);
}
// PUT /api/users/42 — full replacement of the resource
@PutMapping("/{id}")
public UserResponse updateUser(@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
return userService.updateUser(id, request);
}
// DELETE /api/users/42 — 204 No Content is the standard response for delete
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
}
Request and Response DTOs
Separating your API contract from your domain model is important for security and flexibility. Returning an entity directly can accidentally expose internal fields (like password hashes). A response DTO gives you explicit control over what the API surface looks like, independently of how the database model evolves.
// Request DTO — what the client sends; validated before reaching the service
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Size(min = 8) String password
) {}
// Response DTO — what the API returns; never expose the entity or password hash directly
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {
// Static factory — converts the domain entity to the API response shape
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(),
user.getEmail(), user.getCreatedAt());
}
}
Path Variables
Path variables identify a specific resource. Use them for the resource’s identity — the thing that makes this resource unique in the URL space.
// Single path variable — most common case
@GetMapping("/posts/{postId}")
public Post getPost(@PathVariable Long postId) { ... }
// Multiple path variables — for nested resources
@GetMapping("/users/{userId}/orders/{orderId}")
public Order getOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }
// Optional path variable — same method handles both /items and /items/electronics
@GetMapping({"/items", "/items/{category}"})
public List<Item> getItems(@PathVariable(required = false) String category) { ... }
Query Parameters
Query parameters are for optional modifiers — filtering, searching, sorting, and pagination. They don’t change which resource you’re addressing; they change how you want that resource returned. Always provide sensible defaults so callers don’t have to specify them for the common case.
// All params have defaults so /api/products works without any query string
@GetMapping("/products")
public Page<Product> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String search, // null if not provided
@RequestParam(defaultValue = "name") String sortBy) {
return productService.findAll(page, size, search, sortBy);
}
// GET /api/products?page=1&size=10&search=widget&sortBy=price
Request Body
@RequestBody tells Spring to deserialise the JSON request body into the annotated parameter. Jackson handles the conversion automatically. Pairing it with @Valid ensures the object is validated before your method runs — if validation fails, Spring throws a MethodArgumentNotValidException before the body of your method is ever reached.
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest req) {
OrderResponse order = orderService.create(req);
// Return 201 with a Location header pointing to the new resource
URI location = URI.create("/api/orders/" + order.id());
return ResponseEntity.created(location).body(order);
}
Spring uses Jackson to deserialise JSON → Java and serialise Java → JSON automatically.
ResponseEntity — Fine-Grained Control
ResponseEntity lets you set the exact HTTP status code, response headers, and body independently. Use it when the response varies — for example, returning 200 with a body on success but 404 with no body when the resource doesn’t exist.
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
// map() returns 200 with body; orElse() returns 404 with no body
return userService.findById(id)
.map(user -> ResponseEntity.ok(UserResponse.from(user)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest req) {
UserResponse created = userService.create(req);
URI location = URI.create("/api/users/" + created.id());
return ResponseEntity
.created(location) // 201 Created + Location header pointing to the new resource
.body(created);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build(); // 204 No Content — successful, nothing to return
}
HTTP Status Codes
Choosing the right status code communicates intent clearly to clients and intermediate layers like load balancers and CDNs.
| Scenario | Status | ResponseEntity |
|---|---|---|
| OK, returns body | 200 | ResponseEntity.ok(body) |
| Created, returns new resource | 201 | ResponseEntity.created(uri).body(body) |
| Updated/deleted, no body | 204 | ResponseEntity.noContent().build() |
| Bad request data | 400 | ResponseEntity.badRequest().body(error) |
| Unauthorized | 401 | ResponseEntity.status(401).build() |
| Forbidden | 403 | ResponseEntity.status(403).build() |
| Not found | 404 | ResponseEntity.notFound().build() |
| Conflict (duplicate) | 409 | ResponseEntity.status(409).body(error) |
| Server error | 500 | (handled by global exception handler) |
Request Headers
Headers carry metadata about the request — authentication tokens, client identifiers, content negotiation. @RequestHeader binds a header value to a method parameter, with required = false for optional headers.
@GetMapping("/secure-data")
public Data getSecureData(
@RequestHeader("Authorization") String authHeader,
@RequestHeader(value = "X-Request-ID", required = false) String requestId) {
// authHeader will be like "Bearer eyJ..." for JWT-secured endpoints
// requestId may be null if the client didn't send it
}
Full CRUD Example — Product API
A complete, production-style controller showing all the patterns together:
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// GET /api/products?page=0&size=20&category=electronics
@GetMapping
public ResponseEntity<List<ProductResponse>> getAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String category) {
return ResponseEntity.ok(productService.findAll(page, size, category));
}
// GET /api/products/5 — 200 with body or 404
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getById(@PathVariable Long id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/products — 201 Created with Location header
@PostMapping
public ResponseEntity<ProductResponse> create(@RequestBody @Valid CreateProductRequest req) {
ProductResponse product = productService.create(req);
return ResponseEntity
.created(URI.create("/api/products/" + product.id()))
.body(product);
}
// PUT /api/products/5 — full replacement, 200 or 404
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> update(@PathVariable Long id,
@RequestBody @Valid UpdateProductRequest req) {
return productService.update(id, req)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// PATCH /api/products/5/stock?quantity=50 — partial update, 204
@PatchMapping("/{id}/stock")
public ResponseEntity<Void> updateStock(@PathVariable Long id,
@RequestParam int quantity) {
productService.updateStock(id, quantity);
return ResponseEntity.noContent().build();
}
// DELETE /api/products/5 — 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ResponseEntity.noContent().build();
}
}
CORS Configuration
Browsers block cross-origin requests by default — a frontend on localhost:3000 can’t call an API on localhost:8080 without CORS headers. You configure which origins, methods, and headers are allowed. For production, list only the specific origins your frontend is deployed on.
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000", "https://myapp.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // required if the frontend sends cookies or auth headers
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Or per-controller when you only need CORS for specific endpoints:
@CrossOrigin(origins = "http://localhost:3000")
@RestController
public class ProductController { ... }