Skip to main content

REST API design

Binding annotations

AnnotationBindsTypical use
@RequestParamQuery string, or form fields when the body is application/x-www-form-urlencodedGET /api/list?page=2
@PathVariableA URI template segmentGET /api/items/{id}
@RequestBodyThe request body, deserialised by JacksonJSON payloads on POST, PUT, PATCH
@RequestHeaderA request headerAuthorization, Accept-Language
@ModelAttributeQuery or form fields bound onto an objectHTML 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

MethodPurposeSafeIdempotentBody
GETRetrieve a resourceYesYesNo
POSTCreate a resource, or a non-idempotent actionNoNoYes
PUTReplace a resource entirelyNoYesYes
PATCHModify part of a resourceNoNoYes
DELETERemove a resourceNoYesNo

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

CodeWhen
200 OKSuccessful GET, PUT, PATCH, or DELETE returning a body
201 CreatedPOST created a resource; include Location
204 No ContentSuccess with nothing to return, typically DELETE
400 Bad RequestMalformed syntax or failed validation
401 UnauthorizedMissing or invalid credentials — authentication
403 ForbiddenAuthenticated but not permitted — authorisation
404 Not FoundNo such resource
409 ConflictViolates current state, such as a duplicate key
422 Unprocessable EntitySyntactically 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.