Command Palette

Search for a command to run...

[Advanced Java] Building a REST API with Spring Boot

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.

Three HTTP verbs feeding one REST API box and three status codes coming out

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:

ConstraintWhat 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 interfaceThe verb is the operation. GET reads, POST creates, PUT replaces, DELETE removes. There is no action field in the body.
StatelessnessEvery request carries what the server needs. No session on the server holding "which task you were looking at".
Self-describing messagesContent-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 path of one request through DispatcherServlet, with 404 and 405 branching out of the routing step

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.

XML
<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.

Java
@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:

Bash
curl -i http://localhost:18095/api/task/1
curl -i -X PATCH http://localhost:18095/api/tasks/1
Text
HTTP/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:

Java
@Controller
public class LegacyController {
 
    @GetMapping("/api/version")
    @ResponseBody
    public String version() {
        return "v1";
    }
}
Bash
curl -i http://localhost:18095/api/version
Text
HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
 
v1

Note 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:

ShortcutEquivalentTypical 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.

Java
@GetMapping
public List<Task> list(
        @RequestParam(required = false) Boolean done,
        @RequestParam(defaultValue = "20") int limit) {
    return store.findAll(done, limit);
}
Java
@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);
}
AnnotationSourceMissing value
@PathVariablea {placeholder} in the pathcannot be missing; a value of the wrong type is a 400
@RequestParamthe query string or form datarequired by default, so a 400 — unless required = false or defaultValue
@RequestHeadera request headersame rule as @RequestParam
@RequestBodythe request body, deserialisedrequired 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:

Bash
curl -s -H 'X-Request-Id: abc-123' http://localhost:18095/api/echo
curl -s http://localhost:18095/api/echo
JSON
{"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.

Java
@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:

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"Write the API layer","priority":2}'
Text
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:

Bash
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
JSON
[{"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:

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"write the api layer","priority":3}'
Text
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:

Bash
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/2
Text
HTTP/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.

Three lanes — the returned value, the mapped exception, and Spring answering for you — converging on one status line

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:

Java
@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:

Java
@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 { }
Bash
curl -i http://localhost:18095/api/tasks/999
Text
HTTP/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:

Bash
curl -i http://localhost:18095/api/boom
Text
HTTP/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:

Text
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

CodeMeaningWhen you return it
200 OKsuccess with a bodyreads, and updates that return the new state
201 Createda new resource existsPOST that created something; set Location
204 No Contentsuccess, nothing to sayDELETE, and updates you choose not to echo
400 Bad Requestthe request is malformedunparseable JSON, wrong parameter type, missing required header
404 Not Foundno such resourcethe id does not exist, or no mapping owns the path
405 Method Not Allowedwrong verb for that pathSpring answers this for you, with Allow
409 Conflictvalid request, impossible against current stateduplicate key, version mismatch, delete of something in use
415 Unsupported Media Typeyou cannot read what was sentContent-Type: text/plain on a JSON endpoint
422 Unprocessable Contentsyntactically fine, semantically wrongconstraint violations on a well-formed body
500 Internal Server Erroryour bugnever 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.

Java
public record CreateTaskRequest(
        @NotBlank @Size(max = 60) String title,
        @Min(1) @Max(5) int priority) {}
Java
@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.

The validation pipeline forking into a thin default 400 body and a problem+json 422 body listing each field

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"","priority":9}'
Text
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.

Java
@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:

Text
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:

JSON
{"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:

JSON
{"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 ProblemDetailResponseEntityExceptionHandler deliberately does not catch Exception. Add an @ExceptionHandler(Exception.class) only if you want one, and if you do, log the real exception and return a generic detail; never put ex.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:

Properties
spring.mvc.problemdetails.enabled=true

With 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.

Bash
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/1
Text
HTTP/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: 0

Both 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:

Bash
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}}'
Text
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:

Properties
spring.jackson.deserialization.fail-on-unknown-properties=true

A 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.

Java
@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:

Text
[INFO] Running com.example.demo.TaskControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The 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.

Related Posts

[Advanced Java] Building a Real Project: a Sales REST API with Spring Boot

A complete sales management REST API built end to end on Spring Boot 4.1.1, Hibernate 7.4.5 and Java 21: four JPA entities with the owning side marked, the generated schema, services that hold every rule, controllers with DTOs and Location headers, one @RestControllerAdvice mapping four exceptions to 400, 404 and 409, and a full worked curl session including a transaction rollback proved in the SQL log.

[Advanced Java] Connecting Spring Boot to a Database with Spring Data JPA

Spring Data JPA on Spring Boot 4.1.1 and Hibernate 7.4.5: how JDBC, JPA, Hibernate and repositories stack, entity mapping and the generated DDL, derived query methods, the N+1 problem counted in real SQL logs, LazyInitializationException, proxy-based @Transactional, dirty checking and flush versus commit.

[Advanced Java] Unit Testing in Java with JUnit 5

Unit testing in Java with JUnit 5.11.3 on OpenJDK 21: the Platform, Jupiter and Vintage split, the lifecycle callbacks, one new test instance per method, real assertion failure messages, assertThrows and assertAll, DisplayName, Nested, Disabled and Tag, parameterized tests with every argument source, assumptions versus assertions, and the habits that make a test worthless.

[Advanced Java] Buffered Streams and Object Serialization in Java

Advanced java.io on OpenJDK 21: the four abstract stream roots and the exact place a charset is chosen, the decorator chain and why its order matters, buffering measured as call counts rather than milliseconds, DataOutputStream and its big-endian layout, and object serialization end to end — the real byte format, transient, serialVersionUID, writeObject, Externalizable, object graphs, and the ObjectInputFilter that exists because the format is unsafe.