REST Controllers
A REST controller receives HTTP requests, calls your service layer and returns data (usually JSON). Spring Boot converts Java objects to JSON and back automatically.
Mapping annotations
| Annotation | HTTP method | Typical use |
|---|---|---|
| @GetMapping | GET | Read data |
| @PostMapping | POST | Create data |
| @PutMapping | PUT | Replace data |
| @PatchMapping | PATCH | Change part of data |
| @DeleteMapping | DELETE | Delete data |
| @RequestMapping | Any | Class-level base path |
Complete controller
import java.net.URI;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
public record Product(Long id, String name, double price) { }
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final Map<Long, Product> store = new LinkedHashMap<>();
private final AtomicLong ids = new AtomicLong();
@GetMapping // GET /api/products?minPrice=10
public List<Product> list(@RequestParam(defaultValue = "0") double minPrice) {
return store.values().stream().filter(p -> p.price() >= minPrice).toList();
}
@GetMapping("/{id}") // GET /api/products/5
public ResponseEntity<Product> get(@PathVariable Long id) {
Product p = store.get(id);
return p == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(p);
}
@PostMapping // POST /api/products (JSON body)
public ResponseEntity<Product> create(@RequestBody Product input) {
Product saved = new Product(ids.incrementAndGet(), input.name(), input.price());
store.put(saved.id(), saved);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(saved.id()).toUri();
return ResponseEntity.created(location).body(saved); // 201 + Location header
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody Product input) {
if (!store.containsKey(id)) return ResponseEntity.notFound().build();
Product updated = new Product(id, input.name(), input.price());
store.put(id, updated);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
return store.remove(id) == null ? ResponseEntity.notFound().build() : ResponseEntity.noContent().build();
}
}curl -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Pen","price":10}'
curl localhost:8080/api/products
curl -X DELETE localhost:8080/api/products/1Important HTTP status codes
| Code | Meaning | Use |
|---|---|---|
| 200 OK | Success | GET, PUT |
| 201 Created | Resource created | POST (send a Location header) |
| 204 No Content | Success, no body | DELETE |
| 400 Bad Request | Invalid input | Validation errors |
| 401 Unauthorized | Not logged in | Missing or bad token |
| 403 Forbidden | Logged in but not allowed | Missing role |
| 404 Not Found | Resource missing | Unknown id |
| 409 Conflict | State conflict | Duplicate value |
| 500 Internal Server Error | Server bug | Unhandled exception |
Other request features
- @RequestHeader reads a header, @CookieValue reads a cookie.
- @RequestMapping(produces = 'application/json', consumes = 'application/json') restricts media types.
- Pageable parameter: /api/products?page=0&size=10&sort=price,desc works with Spring Data.
- @ResponseStatus(HttpStatus.CREATED) sets a fixed status.
- Spring Framework 7 (Boot 4) adds built-in API versioning support for paths, headers and parameters.
How Spring binds arguments and writes the result
| Parameter you write | Resolver reads from | Notes |
|---|---|---|
| @PathVariable Long id | URL template /products/{id} | Converted by ConversionService, wrong type gives 400 |
| @RequestParam String q | Query string ?q= | required=true by default, defaultValue possible |
| @RequestBody ProductRequest r | Body, via HttpMessageConverter (Jackson) | @Valid triggers validation. Bad JSON gives 400 |
| @RequestHeader / @CookieValue | Headers and cookies | |
| @ModelAttribute (form data) | Form fields or query parameters into an object | For form posts, not JSON |
| HttpServletRequest / Principal / Authentication | Servlet API and security | Injected by argument resolvers |
| Pageable / Sort | ?page=&size=&sort= | Needs Spring Data web support (auto-configured) |
| MultipartFile | multipart/form-data | See the file upload page |
| Return type | What happens |
|---|---|
| POJO / record / List | Written as JSON with status 200 |
| ResponseEntity<T> | Full control of status, headers and body |
| void + @ResponseStatus | Empty body with the status |
| String in @Controller | A view name (Thymeleaf template). In @RestController: plain text |
| Callable / DeferredResult / CompletableFuture | Async: the request thread is released and the result is written later |
| StreamingResponseBody / SseEmitter | Streaming large or long-lived responses |
| ProblemDetail | RFC 9457 error body |
Content negotiation
@GetMapping(value = "/api/products/{id}", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE })
public Product get(@PathVariable Long id) { return service.get(id); }
// Accept: application/json -> JSON Accept: application/xml -> XML (needs jackson-dataformat-xml)
// No matching type -> 406 Not Acceptable Body in a wrong Content-Type -> 415 Unsupported Media TypeConditional GET with ETag (saves bandwidth)
@GetMapping("/api/products/{id}")
public ResponseEntity<ProductResponse> get(@PathVariable Long id, WebRequest request) {
ProductResponse product = service.get(id);
String etag = "\"" + product.version() + "\"";
if (request.checkNotModified(etag)) { // compares If-None-Match with the ETag
return null; // Spring sends 304 Not Modified with an empty body
}
return ResponseEntity.ok().eTag(etag).cacheControl(CacheControl.maxAge(Duration.ofMinutes(1))).body(product);
}Async return types
@GetMapping("/api/reports/{id}")
public CompletableFuture<Report> report(@PathVariable Long id) {
return CompletableFuture.supplyAsync(() -> reportService.build(id), executor); // servlet thread is free while it runs
}
@GetMapping("/api/slow")
public Callable<String> slow() {
return () -> { Thread.sleep(2000); return "done"; }; // runs on the MVC async executor
}
// spring.mvc.async.request-timeout=30sHTTP semantics to respect
| Method | Safe | Idempotent | Body | Typical status |
|---|---|---|---|---|
| GET | Yes | Yes | No | 200, 304, 404 |
| POST | No | No | Yes | 201 with Location, 202 for async work |
| PUT | No | Yes | Yes (full) | 200 or 204 |
| PATCH | No | Not guaranteed | Yes (partial) | 200 or 204 |
| DELETE | No | Yes | Usually no | 204, 404 is also acceptable |
API design rules that age well
- Nouns and plurals in paths: /api/orders/42/items, not /api/getOrderItems.
- Use query parameters for filtering, sorting and paging: /api/orders?status=PAID&sort=createdAt,desc&page=0&size=20.
- Version deliberately (/api/v1/... or a header). Never change the meaning of existing fields.
- Return DTOs, not entities. Use one error format (ProblemDetail).
- Make POST safe to retry with an Idempotency-Key header for payments and orders.
- Always paginate collections and cap the page size (for example max 100).
Common mistake: Returning entities directly from controllers. It exposes your database structure and can trigger lazy loading errors. Return DTOs (records) instead.
Interview questions
What is the difference between @Controller and @RestController?
@RestController adds @ResponseBody to every method, so return values are written to the response body (JSON) instead of being treated as view names.
What is the difference between PUT and PATCH?
PUT replaces the whole resource. PATCH changes only some fields.
@RequestParam or @PathVariable?
@PathVariable reads a part of the URL path (/products/5). @RequestParam reads a query parameter (?page=2).
How do I return 201 with a Location header?
Use ResponseEntity.created(uri).body(dto). Build the uri from the request with ServletUriComponentsBuilder.
Why does a request with a wrong JSON field type return 400?
Jackson throws HttpMessageNotReadableException while reading the body. Spring maps it to 400. You can customize the response in @RestControllerAdvice.