REST API design
Binding annotations
| Annotation | Binds | Typical use |
|---|---|---|
@RequestParam | Query string, or form fields when the body is application/x-www-form-urlencoded | GET /api/list?page=2 |
@PathVariable | A URI template segment | GET /api/items/{id} |
@RequestBody | The request body, deserialised by Jackson | JSON payloads on POST, PUT, PATCH |
@RequestHeader | A request header | Authorization, Accept-Language |
@ModelAttribute | Query or form fields bound onto an object | HTML form submission |
@RequestParam does not read headers — that is @RequestHeader. The two get
confused because both look like "request metadata"; they read entirely different
parts of the message.
@RestController combines @Controller and @ResponseBody, so every handler
return value is serialised into the response body instead of being resolved as a
view name. Do not accept Model in a @RestController method: it exists to
pass attributes to a view, and there is no view.
Method semantics
| Method | Purpose | Safe | Idempotent | Body |
|---|---|---|---|---|
| GET | Retrieve a resource | Yes | Yes | No |
| POST | Create a resource, or a non-idempotent action | No | No | Yes |
| PUT | Replace a resource entirely | No | Yes | Yes |
| PATCH | Modify part of a resource | No | No | Yes |
| DELETE | Remove a resource | No | Yes | No |
Safe means no observable state change. Idempotent means repeating the identical request leaves the same state — which is what lets a client retry after a timeout without checking first.
GET
Retrieves a resource. Parameters go in the query string; a GET request carries no body, and intermediaries are free to discard one.
curl -X GET -H "Accept: application/json" "http://localhost/api/list?page=1&size=20"
@GetMapping(value = "/api/list", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ItemDto>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(service.list(page, size));
}
curl -d sends a body and implies POST. Combining it with -X GET forces the
method while still sending the body — a malformed request that some servers
accept and others silently drop. Use the query string.
POST
Creates a resource, or performs an action that is not idempotent.
curl -X POST -H "Content-Type: application/json" \
-d '{"name":"example"}' http://localhost/api/items
@PostMapping(value = "/api/items",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ItemDto> create(@Valid @RequestBody CreateItemRequest request) {
ItemDto created = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
201 Created with a Location header is the correct response, not 200 OK.
ResponseEntity.created(...) sets both.
File uploads are the exception to the JSON rule — they use
multipart/form-data:
@PostMapping(value = "/api/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> upload(@RequestPart MultipartFile file) { /* ... */ }
PUT
Replaces a resource in full. Fields omitted from the body are cleared, not left alone — that is what makes PUT idempotent and what distinguishes it from PATCH.
curl -X PUT -H "Content-Type: application/json" \
-d '{"name":"new name"}' http://localhost/api/items/42
@PutMapping(value = "/api/items/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ItemDto> replace(@PathVariable String id,
@Valid @RequestBody UpdateItemRequest request) {
return ResponseEntity.ok(service.replace(id, request));
}
Use PATCH when only some fields are sent. Sending a partial body to PUT and expecting a merge is the most common REST modelling error.
DELETE
Removes or hides a resource. {id} is the unique identifier.
curl -X DELETE http://localhost/api/items/42
@DeleteMapping("/api/items/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
service.delete(id);
return ResponseEntity.noContent().build(); // 204
}
DELETE is idempotent: deleting an already-deleted resource should still return
204, not 500. Returning 404 for the second call is defensible but makes
client retries harder.
Status codes
| Code | When |
|---|---|
| 200 OK | Successful GET, PUT, PATCH, or DELETE returning a body |
| 201 Created | POST created a resource; include Location |
| 204 No Content | Success with nothing to return, typically DELETE |
| 400 Bad Request | Malformed syntax or failed validation |
| 401 Unauthorized | Missing or invalid credentials — authentication |
| 403 Forbidden | Authenticated but not permitted — authorisation |
| 404 Not Found | No such resource |
| 409 Conflict | Violates current state, such as a duplicate key |
| 422 Unprocessable Entity | Syntactically valid but semantically wrong |
401 and 403 are routinely swapped. 401 means "I do not know who you are"; 403 means "I know who you are and the answer is still no".
Consistent errors
Return the same shape for every failure. Spring Boot 3 implements RFC 7807 problem details:
spring.mvc.problemdetails.enabled=true
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ItemNotFoundException.class)
ProblemDetail handleNotFound(ItemNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Item not found");
return problem;
}
}
Never let an exception message reach the client unfiltered. Stack traces and SQL fragments in error bodies are a reliable source of information disclosure.