An HTTP API has a very small public surface: a URL, a verb, a status code, and two blobs of JSON. Everything else — your services, your objects, your clever internal design — is invisible to the client. That is why the interesting part of writing one is not the controller class but the decisions the controller encodes: which URL names which thing, which verb changes it, and which number comes back when it goes wrong.
Spring Boot makes the mechanical half nearly free. What it does not make free is the half that clients actually feel, which is the status codes and the error bodies. This article builds a small CRUD resource, exercises every endpoint with real HTTP, and spends most of its space on the outcomes: 201 with a Location header, 204 on delete, 409 on a conflict, 422 with a body that names the field that failed.
![]()
Everything below ran on Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5, Hibernate Validator 9.1.3.Final) on OpenJDK 21.0.6 on arm64. Every status line, header and response body is copied out of a live terminal, not written from memory. Startup and per-request durations have been stripped from the transcripts on purpose — a millisecond count from one machine on one afternoon tells you nothing useful, so this article publishes none.
What REST actually asks for
REST is an architectural style, and the parts of it that survive into everyday work are four:
| Constraint | What it means in a controller |
|---|---|
| Resources, named by URL | /api/tasks is the collection, /api/tasks/1 is one member. The URL names a thing, not an action — no /api/getTask. |
| A uniform interface | The verb is the operation. GET reads, POST creates, PUT replaces, DELETE removes. There is no action field in the body. |
| Statelessness | Every request carries what the server needs. No session on the server holding "which task you were looking at". |
| Self-describing messages | Content-Type says what you sent, Accept says what you want, and the status code says what happened. |
Being honest about the rest of it: the great majority of things called REST APIs are HTTP plus JSON with sensible URLs, and stop well short of Roy Fielding's full definition. Hypermedia controls — responses that carry links telling the client what it may do next — are the constraint almost nobody implements, and an API without them is not, strictly, REST. In practice this distinction costs you nothing. Name resources with nouns, use the verbs for what they mean, and return honest status codes, and your API will be indistinguishable in daily use from one that satisfies the purists. What clients genuinely suffer from is not a missing link relation; it is a 200 OK wrapping {"error": "not found"}.
The one place the strict reading earns its keep is caching and safety. GET must not change anything, because proxies, browsers and retry logic all assume it does not. PUT and DELETE should be idempotent — sending them twice leaves the same state as sending them once. POST is neither, which is exactly why it is the one that creates.
From the socket to your method
A Spring MVC application has one servlet. DispatcherServlet receives every request that reaches the application and decides, from the path and the method together, which of your handler methods should run.

The stages are worth naming because they tell you where a failure came from. Handler mapping fails before your code runs. Argument binding fails before your code runs. A body that will not parse fails before your code runs. Only after all three succeed does your method get called, and only after your method returns does Jackson turn the return value into JSON.
The project is a plain Spring Initializr build with two starters. Note the artifact names: on Spring Boot 4, the Initializr web choice resolves to spring-boot-starter-webmvc rather than the spring-boot-starter-web you will remember from Boot 3.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>A controller is a bean with request mappings on it. Assume, as the previous article established, that component scanning finds it and constructor injection supplies its collaborators.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskStore store;
TaskController(TaskStore store) {
this.store = store;
}
@GetMapping("/{id}")
public Task one(@PathVariable long id) {
return store.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
}Routing is easy to prove. Ask for a path nothing is mapped to, and ask for a mapped path with a verb it does not support:
curl -i http://localhost:18095/api/task/1
curl -i -X PATCH http://localhost:18095/api/tasks/1HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.833Z","status":404,"error":"Not Found","path":"/api/task/1"}
HTTP/1.1 405
Allow: DELETE, GET, PUT
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.845Z","status":405,"error":"Method Not Allowed","path":"/api/tasks/1"}That is the distinction people get wrong. A 404 means no mapping owns the path at all. A 405 means the path matched and the verb did not — and Spring proves it by listing the verbs that would have worked in the Allow header, which is required by the HTTP specification and which you get for free. The order of the verbs inside that header is not significant and is not stable between runs of the same application; only the set is.
The mapping annotations
@RestController versus @Controller plus @ResponseBody
@RestController is exactly @Controller and @ResponseBody combined, applied to the whole class. @ResponseBody is the part that matters: it says the return value is the response body, to be written by a message converter, rather than a view name to be resolved into a template. Written the long way:
@Controller
public class LegacyController {
@GetMapping("/api/version")
@ResponseBody
public String version() {
return "v1";
}
}curl -i http://localhost:18095/api/versionHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
v1Note the content type. Returning a String gets you text/plain, not JSON — the converter chosen depends on the return type, and a bare String is handled by the string converter. This surprises people who expect @RestController to mean "always JSON". It does not; it means "the return value is the body".
Drop @ResponseBody from that method and Spring treats "v1" as a view name and tries to resolve a template called v1. That is the entire practical difference between the two annotations.
Path, verb, and the shortcut annotations
@RequestMapping is the general form and takes the verb as an attribute. The shortcuts are just pre-filled aliases of it, and they are what you should write:
| Shortcut | Equivalent | Typical use |
|---|---|---|
@GetMapping | @RequestMapping(method = RequestMethod.GET) | read a resource or a collection |
@PostMapping | @RequestMapping(method = RequestMethod.POST) | create a new member of a collection |
@PutMapping | @RequestMapping(method = RequestMethod.PUT) | replace a member wholesale |
@PatchMapping | @RequestMapping(method = RequestMethod.PATCH) | apply a partial change |
@DeleteMapping | @RequestMapping(method = RequestMethod.DELETE) | remove a member |
@RequestMapping on the class is a prefix for every method in it, which is why @RequestMapping("/api/tasks") on the class plus @GetMapping("/{id}") on the method produces GET /api/tasks/{id}. Both annotations also take consumes and produces, which narrow a mapping to particular media types — useful when two handlers share a path and differ only in what they accept.
Binding path, query and header values
Four annotations pull the pieces of a request into method parameters, and each has a different rule for what happens when the value is absent.
@GetMapping
public List<Task> list(
@RequestParam(required = false) Boolean done,
@RequestParam(defaultValue = "20") int limit) {
return store.findAll(done, limit);
}@GetMapping("/api/echo")
public Map<String, String> echo(
@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(value = "X-Tenant", defaultValue = "public") String tenant) {
return Map.of("requestId", requestId, "tenant", tenant);
}| Annotation | Source | Missing value |
|---|---|---|
@PathVariable | a {placeholder} in the path | cannot be missing; a value of the wrong type is a 400 |
@RequestParam | the query string or form data | required by default, so a 400 — unless required = false or defaultValue |
@RequestHeader | a request header | same rule as @RequestParam |
@RequestBody | the request body, deserialised | required by default; an unparseable body is a 400 |
Both defaults hold up under test. Sending the header gets the echo; omitting it gets a 400 whose body, as shipped, does not say which header was missing — hold that thought, because fixing it is what the error-handling section is about:
curl -s -H 'X-Request-Id: abc-123' http://localhost:18095/api/echo
curl -s http://localhost:18095/api/echo{"requestId":"abc-123","tenant":"public"}
{"timestamp":"2026-09-10T08:57:21.970Z","status":400,"error":"Bad Request","path":"/api/echo"}Conversion is worth a moment. @PathVariable long id means Spring must turn the string "1" into a long, and when it cannot the request never reaches you. GET /api/tasks/abc answers 400, as does GET /api/tasks?limit=lots. Declaring the parameter as String and parsing it yourself only moves that failure into your method, where it becomes your problem to report.
The whole resource, and every endpoint exercised
The store is an in-memory ConcurrentHashMap with an AtomicLong for ids — persistence is the next article's subject, and nothing here depends on it.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskStore store;
TaskController(TaskStore store) {
this.store = store;
}
@GetMapping
public List<Task> list(
@RequestParam(required = false) Boolean done,
@RequestParam(defaultValue = "20") int limit) {
return store.findAll(done, limit);
}
@GetMapping("/{id}")
public Task one(@PathVariable long id) {
return store.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) {
Task created = store.create(body.title(), body.priority());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
@PutMapping("/{id}")
public Task replace(@PathVariable long id, @Valid @RequestBody CreateTaskRequest body) {
return store.replace(id, body.title(), body.priority());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
store.delete(id);
}
}Every endpoint, run against the live application:
curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"Write the API layer","priority":2}'HTTP/1.1 201
Location: /api/tasks/1
Content-Type: application/json
Transfer-Encoding: chunked
{"id":1,"title":"Write the API layer","priority":2,"done":false}After a second POST created "Add validation", the reads:
curl -s http://localhost:18095/api/tasks
curl -s 'http://localhost:18095/api/tasks?done=false&limit=1'
curl -s http://localhost:18095/api/tasks/1[{"id":1,"title":"Write the API layer","priority":2,"done":false},{"id":2,"title":"Add validation","priority":1,"done":false}]
[{"id":1,"title":"Write the API layer","priority":2,"done":false}]
{"id":1,"title":"Write the API layer","priority":2,"done":false}The store rejects a second task with the same title, case-insensitively, by throwing a DuplicateTitleException — which is a 409, not a 400, because nothing about the request is malformed:
curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"write the api layer","priority":3}'HTTP/1.1 409
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.937Z","status":409,"error":"Conflict","path":"/api/tasks"}Then the replace and the delete:
curl -i -X PUT http://localhost:18095/api/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"title":"Write the API layer (v2)","priority":1}'
curl -i -X DELETE http://localhost:18095/api/tasks/2HTTP/1.1 200
Content-Type: application/json
Content-Length: 69
{"id":1,"title":"Write the API layer (v2)","priority":1,"done":false}
HTTP/1.1 204 A 204 has no body at all, which is why nothing follows the headers. Sending the same DELETE a second time answers 404, because there is nothing left to delete — the state is idempotent, the status code is not, and that is normal.
Status codes, and where each one comes from
This is the part most tutorials do worst, usually by returning 200 for everything and putting the real outcome in the body. A status code is the return type of your API. Clients branch on it before they parse anything.

ResponseEntity: status, headers and body
Returning a plain object always means 200. ResponseEntity is how you say something else, and how you set a header:
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) {
Task created = store.create(body.title(), body.priority());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}ResponseEntity.created(uri) does two things at once: it sets the status to 201 and writes the Location header. That header is the point of a 201 — it tells the client the URL of the thing that now exists, which is information the client cannot construct itself because the server assigned the id. The capture above shows Location: /api/tasks/1, relative because that is the URI passed in; an absolute URL is equally valid and is what you would build from the current request when your API is served behind a proxy.
For a delete there is nothing to return, and @ResponseStatus(HttpStatus.NO_CONTENT) on a void method is the shortest way to say 204. ResponseEntity.noContent().build() is the same answer written as a value.
@ResponseStatus on the exception class
A handler that has nothing sensible to return should throw. Annotating the exception type maps it to a status with no other wiring at all:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(long id) {
super("No task with id " + id);
}
}
@ResponseStatus(value = HttpStatus.CONFLICT, reason = "A task with that title already exists")
public class DuplicateTitleException extends RuntimeException { }curl -i http://localhost:18095/api/tasks/999HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.929Z","status":404,"error":"Not Found","path":"/api/tasks/999"}409 works the same way when a create collides with an existing title. Two things about that default body are worth noticing now, because they motivate the next two sections: it does not contain your exception's message, and it does not contain the reason you wrote on the annotation either. Spring is being careful — an exception message can carry internals you would rather not publish — but the result is a body that tells the client nothing it did not already learn from the status line.
An exception you have not mapped at all becomes a 500:
curl -i http://localhost:18095/api/boomHTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.992Z","status":500,"error":"Internal Server Error","path":"/api/boom"}The stack trace stays on the server, where it belongs:
ERROR 57195 --- [demo] [io-18095-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: counter was never initialised] with root cause
java.lang.IllegalStateException: counter was never initialised
at com.example.demo.EchoController.boom(EchoController.java:20) ~[!/:0.0.1-SNAPSHOT]A 500 is a statement about your code, not about the request. If a client can trigger one by sending something, the correct fix is a 4xx that names what was wrong with it.
400 versus 422, and the codes worth memorising
| Code | Meaning | When you return it |
|---|---|---|
| 200 OK | success with a body | reads, and updates that return the new state |
| 201 Created | a new resource exists | POST that created something; set Location |
| 204 No Content | success, nothing to say | DELETE, and updates you choose not to echo |
| 400 Bad Request | the request is malformed | unparseable JSON, wrong parameter type, missing required header |
| 404 Not Found | no such resource | the id does not exist, or no mapping owns the path |
| 405 Method Not Allowed | wrong verb for that path | Spring answers this for you, with Allow |
| 409 Conflict | valid request, impossible against current state | duplicate key, version mismatch, delete of something in use |
| 415 Unsupported Media Type | you cannot read what was sent | Content-Type: text/plain on a JSON endpoint |
| 422 Unprocessable Content | syntactically fine, semantically wrong | constraint violations on a well-formed body |
| 500 Internal Server Error | your bug | never chosen deliberately |
The 400-versus-422 argument is the one worth settling in your own head. RFC 9110 defines 400 as a general client error and 422 as "the request was well-formed but could not be processed", which maps neatly onto the split between "this JSON does not parse" and "this JSON parses and says priority is 9 when the maximum is 5". Spring's default for a @Valid failure is 400. Both codes are defensible; what is not defensible is using them inconsistently across your own API. This article returns 422 for constraint violations and 400 for anything Jackson could not read, and documents that choice — which is the actual requirement.
Validating the request body
Constraints go on the request type, which is a record here. @Valid on the parameter is what makes them run.
public record CreateTaskRequest(
@NotBlank @Size(max = 60) String title,
@Min(1) @Max(5) int priority) {}@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) { ... }Without @Valid, the annotations are inert decoration — the object is built and handed to you unchecked. With it, Bean Validation runs every constraint, collects every violation, and throws MethodArgumentNotValidException before your method is entered. Note the ordering: Jackson builds the object first, so a body that will not parse never reaches a constraint at all.
Here is the same violation described twice — first by Spring with no code of yours, then by an advice.

curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"","priority":9}'HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.983Z","status":400,"error":"Bad Request","path":"/api/tasks"}That is the default, and it is unusable. The server knows two things — that title was blank and that priority exceeded 5 — and tells the client neither. A client receiving this cannot highlight a form field, cannot log anything actionable, and cannot do better than show "something was wrong". The fix is one class.
Error handling with @RestControllerAdvice and ProblemDetail
@RestControllerAdvice is a bean whose @ExceptionHandler methods apply to every controller in the application. Extending ResponseEntityExceptionHandler additionally gives you Spring's own exceptions — unreadable body, missing header, wrong verb — already mapped, so you only override the ones you care about.
ProblemDetail is Spring's implementation of RFC 9457, the standard shape for an HTTP error body: type, title, status, detail, instance, plus any extra members you add. It is built into the framework; there is nothing to add to the build.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<Violation> errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> new Violation(f.getField(),
String.valueOf(f.getDefaultMessage()),
String.valueOf(f.getRejectedValue())))
.toList();
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, "The request body failed validation");
body.setTitle("Validation failed");
body.setType(URI.create("https://api.example.com/problems/validation-failed"));
body.setProperty("errors", errors);
return handleExceptionInternal(ex, body, headers, HttpStatus.UNPROCESSABLE_ENTITY, request);
}
@ExceptionHandler(TaskNotFoundException.class)
ProblemDetail handleNotFound(TaskNotFoundException ex) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
body.setTitle("Task not found");
body.setType(URI.create("https://api.example.com/problems/task-not-found"));
body.setProperty("taskId", ex.id());
return body;
}
record Violation(String field, String message, String rejected) {}
}The same request as before, against the same application with that one class added:
HTTP/1.1 422
Content-Type: application/problem+json
{
"detail": "The request body failed validation",
"instance": "/api/tasks",
"status": 422,
"title": "Validation failed",
"type": "https://api.example.com/problems/validation-failed",
"errors": [
{
"field": "title",
"message": "must not be blank",
"rejected": ""
},
{
"field": "priority",
"message": "must be less than or equal to 5",
"rejected": "9"
}
]
}must not be blank and must be less than or equal to 5 are Hibernate Validator's default messages, verbatim — you did not write them and you do not have to. The content type is application/problem+json, which is what tells a client this is an error document rather than a resource. instance is filled in with the request path automatically.
One caveat found by running this repeatedly: the order of the entries in errors is not stable. Bean Validation returns a Set of violations, so the same request can report title first on one run and priority first on the next. Sort them before you emit them if the order matters to you, and never write a test that asserts on errors[0].
The 404 and 409 handlers produce the same shape, including a custom member:
{"detail":"No task with id 999","instance":"/api/tasks/999","status":404,"title":"Task not found","type":"https://api.example.com/problems/task-not-found","taskId":999}
{"detail":"Another task already uses that title","instance":"/api/tasks","status":409,"title":"Duplicate title","type":"https://api.example.com/problems/duplicate-title"}Spring's own failures come through the inherited handlers, now also as problem+json and now with a usable detail:
{"detail":"Failed to read request","instance":"/api/tasks","status":400,"title":"Bad Request"}
{"detail":"Method 'PATCH' is not supported.","instance":"/api/tasks/1","status":405,"title":"Method Not Allowed"}
{"detail":"Required header 'X-Request-Id' is not present.","instance":"/api/echo","status":400,"title":"Bad Request"}⚠️ An unhandled exception still produces the plain 500 body, not a
ProblemDetail—ResponseEntityExceptionHandlerdeliberately does not catchException. Add an@ExceptionHandler(Exception.class)only if you want one, and if you do, log the real exception and return a generic detail; never putex.getMessage()of an unknown throwable into a response.
If you want RFC 9457 bodies for Spring's own exceptions without writing any class at all, one property does it:
spring.mvc.problemdetails.enabled=trueWith that set and no advice present, a wrong verb answers with application/problem+json and {"detail":"Method 'PATCH' is not supported.","instance":"/api/tasks/1","status":405,"title":"Method Not Allowed"}. It is a good default to turn on; it does not, however, do anything for your own exception types, which is why the advice still earns its place.
Content negotiation and the JSON defaults
Content-Type describes the body you are sending. Accept describes the bodies you are willing to receive. Spring picks a message converter using both, and answers with a status code when it cannot.
curl -i -X POST http://localhost:18095/api/tasks -H 'Content-Type: text/plain' -d 'title=x'
curl -i -H 'Accept: application/xml' http://localhost:18095/api/tasks/1HTTP/1.1 415
Accept: application/json, application/*+json
{"timestamp":"2026-09-10T08:57:22.007Z","status":415,"error":"Unsupported Media Type","path":"/api/tasks"}
HTTP/1.1 406
Accept: application/json, application/*+json
Content-Length: 0Both responses carry an Accept header listing what this endpoint can actually handle — application/json and the +json family, because Jackson is the only converter on the classpath for objects. Add an XML converter to the build and the 406 becomes an XML document; this is what "content negotiation" means in practice.
Serialisation itself is Jackson's job and an earlier article in this series covered it. What is worth knowing here are the defaults Spring Boot sets, because they are not Jackson's own:
curl -i -X POST http://localhost:18095/api/tasks -H 'Content-Type: application/json' \
-d '{"title":"Unknown field demo","priority":3,"colour":"red","nested":{"a":1}}'HTTP/1.1 201
Location: /api/tasks/3
{"id":3,"title":"Unknown field demo","priority":3,"done":false}An unknown field is silently ignored. Spring Boot disables Jackson's FAIL_ON_UNKNOWN_PROPERTIES, which is the opposite of Jackson's own default, on the reasoning that a client sending an extra field should not break when your API adds one later. Flip it back and the same request is a 400:
spring.jackson.deserialization.fail-on-unknown-properties=trueA missing field is different, and this is the trap. Leave priority out entirely and the record is built with 0 for the int — Jackson cannot distinguish "absent" from "zero" for a primitive. That value then fails @Min(1) and the request is rejected, which is the right outcome, but only because a constraint happened to catch it. Had the valid range included zero, an absent field would have been silently accepted as zero. Use a boxed type when absence is meaningful, and let @NotNull say so.
Testing the API with @WebMvcTest
@WebMvcTest starts the web layer only — controllers, converters, the advice — and leaves your services out, which you supply as mocks. MockMvc then drives requests through the full DispatcherServlet pipeline without opening a socket, so routing, binding, validation and the advice all really run.
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
TaskStore store;
@Test
void createReturns201WithLocation() throws Exception {
given(store.create("Write the API layer", 2))
.willReturn(new Task(7, "Write the API layer", 2, false));
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Write the API layer\",\"priority\":2}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/tasks/7"))
.andExpect(jsonPath("$.id").value(7));
}
@Test
void invalidBodyIsUnprocessableEntity() throws Exception {
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"\",\"priority\":9}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.errors.length()").value(2))
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("title", "priority")));
}
}Two Spring Boot 4 details are easy to trip over. @WebMvcTest now lives in org.springframework.boot.webmvc.test.autoconfigure, following the same modularisation that renamed the starter. And @MockBean is gone: the replacement is @MockitoBean from org.springframework.test.context.bean.override.mockito.
Four tests, run with the Maven wrapper:
[INFO] Running com.example.demo.TaskControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSThe second test is the one that pays for itself. It never touches TaskStore — the request never gets that far — so it is a direct assertion about your validation contract and your advice, and it fails the moment somebody removes @Valid.
FAQ
Should a REST API ever return 200 with an error inside the body?
No. That is the single most common mistake in this area, and it defeats every generic client, proxy, retry policy and monitoring rule you will ever put in front of the API, all of which read the status line and nothing else. A 200 means it worked. If it did not work, say so with a 4xx or a 5xx and put the detail in a ProblemDetail body. The only defensible near-exception is a bulk endpoint where some items succeeded and some failed, and even then the honest answer is 207 or a 200 whose body is explicitly a list of per-item outcomes, not a single hidden error.
What is the difference between 400 and 422?
400 means the server could not make sense of the request as a request — the JSON does not parse, a query parameter is not a number, a required header is missing. 422 means the request was perfectly well-formed and the server understood it, but its contents are not acceptable — a blank title, a priority of 9 against a maximum of 5. Spring's default for a @Valid failure is 400; this article overrides it to 422 in the advice. Either is fine as long as you pick one and apply it everywhere, because the value to a client is in the consistency, not in the number.
Do I need @RequestBody on the parameter?
Yes, for a JSON body. Without it Spring treats the parameter as a model attribute and populates it from query parameters and form fields, ignoring the body entirely — the JSON you posted is never read. Removing it from the create handler and posting a perfectly good body was verified to fail with Failed to convert value of type 'null' to required type 'int' on priority, because there was nothing in the query string to bind and a record cannot be built half-empty. With a class of nullable fields instead of a record you get the more confusing version of the same bug: an object of nulls and no error at all. @RequestBody is what routes the parameter through a message converter.
Why is my POST returning 200 instead of 201?
Because a handler that returns a plain object always answers 200 — 201 is never inferred from the fact that the method was mapped to POST. Return ResponseEntity.created(uri).body(created) instead, which sets the status and the Location header together. The Location is the substantive part: it hands the client the URL of the thing that now exists.
Is a 405 something I have to implement?
No. Spring answers it from the mapping table before your code runs, and it includes the Allow header listing the verbs registered for that path. If you find yourself writing a handler that checks the verb and returns 405, the mapping is wrong somewhere — most often a @RequestMapping with no method attribute, which matches every verb and so leaves nothing for Spring to reject.
What does @Valid actually do, and when does it not run?
It marks a parameter for Bean Validation, which then evaluates every jakarta.validation constraint on that object and throws MethodArgumentNotValidException if any fail. It does not run when you forget it — the constraints become inert annotations and the object is passed through unchecked, which is why a validation bug in Spring so often turns out to be one missing word. It also does not run on a body Jackson could not build in the first place; that failure is a 400 and happens earlier. And for validation of nested objects you need @Valid on the nested field as well, since it does not descend by itself.
Do I need spring-boot-starter-validation as a separate dependency?
Yes, and on Spring Boot 4 you find out immediately: removing it and rebuilding fails at compile time with package jakarta.validation.constraints does not exist, because the web starter does not put the Bean Validation API on the classpath at all. The silent version of this failure still exists, though, and it is worth knowing what it looks like — run classes that were compiled against the API on a classpath with no implementation, and @Valid simply does nothing. Verified: the same invalid body that answers 422 with the starter present is accepted with 201 and {"id":1,"title":"","priority":9,"done":false} without it. No error, no warning. If constraints appear to do nothing, check that dependency first.
Conclusion
A controller method is small; the decisions around it are not. Give resources noun URLs and let the verb be the operation. Return the status code that is true — 201 with a Location when you created something, 204 when there is nothing to say, 404 when the id does not exist, 409 when the request is fine but the state will not allow it, 422 when the body parsed and its contents are wrong. And send an error body a client can act on: ProblemDetail is built into the framework, it costs one @RestControllerAdvice, and it turns "Bad Request" into a list of the fields that failed and why.
Everything shown here ran against an in-memory Map, which was deliberate — the HTTP layer has nothing to do with where the data lives, and mixing the two is how articles about REST end up being articles about databases. That separation is also why the code above will not change much when the store does.
Which is exactly where the next article goes: Spring Data JPA, and how a repository interface with no implementation ends up talking to a real database.