How to Build a REST API in Spring Boot, Including the Failures
Annotate a class @RestController, map methods with @GetMapping and @PostMapping, and return an object — Spring turns it into JSON with no configuration. That part takes ten minutes. The afternoon goes on the failures: a body that fails @Valid returns HTTP 400 naming no field, and enabling ProblemDetail does not fix that. Here is both halves, with the real response to every request.
Building a REST API in Spring Boot is genuinely a ten-minute job, and that is the problem with most guides to it. They show the ten minutes — a controller, a @GetMapping, an object that becomes JSON — and stop, because the happy path is the part that was never hard. The afternoon goes somewhere else: a client sends a bad body, gets back HTTP 400 with a response that names no field, and nobody can tell it what to fix.
This article builds the same small API, but calls every endpoint including the broken requests, and prints what actually came back. Everything below is a transcript from a running Spring Boot 4.1.1 application, not a recollection of one.
The shortest controller that answers a request
Start from a generated project with the Spring Web and Validation dependencies. On Spring Boot 4.1.1 those two choices produce four starters: spring-boot-starter-webmvc and spring-boot-starter-validation, plus their test counterparts. You will need a JDK to run any of it — installing Java 21 covers that if you are starting from nothing.
Two files. The thing being served, as a record:
public record Book(Long id, String title, String author, int year) {}
And the controller:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final Map<Long, Book> store = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
@GetMapping
public List<Book> all() {
return List.copyOf(store.values());
}
}
Those two fields are the entire storage layer for this article — a map and a counter, so that nothing below is about a database. They are final and thread-safe on purpose, and it is worth seeing why rather than taking it on trust. A temporary endpoint returning System.identityHashCode(this) and the current thread name, called three times, answers:
instance=1664932888 thread=http-nio-8080-exec-1
instance=1664932888 thread=http-nio-8080-exec-2
instance=1664932888 thread=http-nio-8080-exec-3
One controller instance, three different threads. Spring creates the controller once and every request shares it, while Tomcat serves each request on a thread from its pool. So any mutable field on a controller is shared mutable state across concurrent requests — a plain HashMap there would be a race condition waiting for your second user. That is why this one uses ConcurrentHashMap and an AtomicLong, and why most real controllers hold no state at all and delegate to a service instead.
That is a working endpoint. GET /api/books returns:
[{"id":1,"title":"The Pragmatic Programmer","author":"Hunt and Thomas","year":1999}]
HTTP 200, Content-Type: application/json, and you wrote no serialization code. Two annotations did it:
@RestControlleris@Controllerplus@ResponseBody. That second half is the important one: with a plain@Controller, returning aStringmeans "render the view with this name". With@ResponseBodyon every method, the returned object is the response body.@RequestMapping("/api/books")on the class is a prefix. Every mapping inside it hangs off that path.
The JSON conversion is Jackson, which arrived with the web starter and was configured by auto-configuration. A record works as-is because Jackson reads its accessors.
Mapping the rest of the verbs
@GetMapping("/{id}")
public Book byId(@PathVariable Long id) {
Book found = store.get(id);
if (found == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No book with id " + id);
}
return found;
}
@PostMapping
public ResponseEntity<Book> add(@Valid @RequestBody Book incoming) {
Book saved = create(incoming);
return ResponseEntity.created(URI.create("/api/books/" + saved.id())).body(saved);
}
private Book create(Book incoming) {
long id = nextId.getAndIncrement();
Book saved = new Book(id, incoming.title(), incoming.author(), incoming.year());
store.put(id, saved);
return saved;
}
Note what create does with the incoming object: it builds a new Book and assigns the id itself. The client may send an id in the body and it is ignored — deciding identity is the server's job, and accepting a client-supplied primary key is a way to let a caller overwrite someone else's row.
Three new annotations, each doing one thing:
| Annotation | What it binds |
|---|---|
@PathVariable Long id |
The {id} segment of the URL, converted to Long |
@RequestBody Book incoming |
The request body, deserialised from JSON into a Book |
@Valid |
Runs the record's constraints before the method body does anything |
A record is a good choice for both directions here. It is immutable, it needs no builder, and the fields you declare are exactly the JSON you get.
Note
The API in this article keeps its books in a
ConcurrentHashMap, so the code stays about HTTP rather than about persistence. A real one puts them in a database, and the choice of which is a separate decision — MySQL versus PostgreSQL covers the trade-offs that actually matter there.
The header that makes a POST correct
Returning an object from a method gives you HTTP 200. A creation should say 201 and tell the caller where the new thing lives, and that is what ResponseEntity is for. ResponseEntity.created(uri) sets the status and the Location header in one call.
The real response:
HTTP/1.1 201
Location: /api/books/2
Content-Type: application/json
{"id":2,"title":"Effective Java","author":"Bloch","year":2018}
That Location header is the part people leave out, and it is the difference between an endpoint a client can follow and one it has to guess about. Any Spring Boot service that exposes HTTP does this same job — a Eureka service registry is a Spring Boot application answering REST calls exactly like this one.
Validation: the annotation is the easy half
Add constraints to the record:
public record Book(
Long id,
@NotBlank String title,
@NotBlank String author,
@Positive int year) {}
With @Valid on the @RequestBody parameter, a bad body never reaches your method. Send one:
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"","author":"X","year":-5}'
It is rejected, correctly, with HTTP 400. And here is what the caller receives:
{"timestamp":"...","status":400,"error":"Bad Request","path":"/api/books"}
Read that as the client. Two fields are invalid, and the response says which: nothing. Not the field names, not the reasons, not even that it was a validation problem rather than malformed JSON. A /api/books/abc request — a path variable that will not parse as a Long — returns the identical body. Your API rejects work correctly and explains nothing.
Common mistake
Shipping this. The endpoint passes every test you wrote against the happy path, and the first person to integrate against it spends an hour guessing which field it dislikes.
Why the body is so unhelpful, and what half-fixes it
That shape — timestamp, status, error, path — is Spring Boot's default error representation, and by default it has no message member. This is also why the text passed to ResponseStatusException vanishes: GET /api/books/999 throws with "No book with id 999", and the caller gets
{"timestamp":"...","status":404,"error":"Not Found","path":"/api/books/999"}
Spring has a better answer built in. ProblemDetail implements RFC 9457, Problem Details for HTTP APIs — a standard document shape with type, title, status, detail and instance members, served as application/problem+json. It is off by default. One property turns it on:
spring.mvc.problemdetails.enabled=true
The 404 improves immediately:
{"detail":"No book with id 999","instance":"/api/books/999","status":404,"title":"Not Found"}
The message reaches the caller, the media type is now application/problem+json, and the shape is one any client library can be taught once.
The gap ProblemDetail does not close
Now send the invalid body again, with ProblemDetail enabled:
{"detail":"Invalid request content.","instance":"/api/books","status":400,"title":"Bad Request"}
Still no field. This is worth stating plainly because it contradicts the advice you will find most often: enabling ProblemDetail does not fix validation feedback. It fixes the shape and it lets a message through, but the field errors are not in it.
The other advice you will find is a property, and on Spring Boot 4 it has a new name. server.error.include-binding-errors was renamed to spring.web.error.include-binding-errors in 4.0, and the old name is now ignored with no warning at startup. Set the new one with ProblemDetail enabled and the body above comes back unchanged. Turn ProblemDetail off, though, and the default body gains an errors array (the second entry is cut short here):
spring.web.error.include-binding-errors=always
{"timestamp":"...","status":400,"error":"Bad Request",
"errors":[{"objectName":"book","field":"year","rejectedValue":-5,
"codes":["Positive.book.year","Positive.year","Positive.int","Positive"],
"arguments":[{"arguments":null,"code":"year","codes":["book.year","year"],"defaultMessage":"year"}],
"bindingFailure":false,"code":"Positive","defaultMessage":"must be greater than 0"},
{"objectName":"book","field":"title","rejectedValue":"", ...}],
"path":"/api/books"}
So you can have the fields, or you can have a problem document with a message in it, but this property will not give you both. The fields also come with everything around them: the value the caller sent, the internal object name, and Spring's message-resolution codes.
That is a defensible default rather than an oversight. Field names and constraint messages describe your internal model, and Spring will not push them onto the wire unless you decide to. The decision is yours; you just have to make it.
Making a failure actionable in fifteen lines
Extend ResponseEntityExceptionHandler in a @RestControllerAdvice and override the one method that handles a failed @Valid:
@RestControllerAdvice
public class ValidationErrors extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> fields.put(e.getField(), e.getDefaultMessage()));
ProblemDetail body = ProblemDetail.forStatusAndDetail(status, "One or more fields are invalid");
body.setTitle("Validation failed");
body.setProperty("errors", fields);
return ResponseEntity.status(status).body(body);
}
}
setProperty adds an extension member — RFC 9457 allows a problem document to carry additional fields beyond the standard five, which is exactly what this is for. The same request now answers:
{"detail":"One or more fields are invalid","instance":"/api/books","status":400,
"title":"Validation failed",
"errors":{"title":"must not be blank","year":"must be greater than 0"}}
Those two messages are the Jakarta Bean Validation defaults for @NotBlank and @Positive. You did not write them, and you can override any of them with a message attribute on the constraint when the default is too terse for a caller.
One class, and every validation failure in the application becomes something a client can act on.
Testing it without starting a server
You do not need a running application to assert any of the above. @WebMvcTest loads the web layer only and hands you a MockMvc that calls the controller directly:
@WebMvcTest(BookController.class)
class BookControllerTest {
@Autowired MockMvc mvc;
@Test
void listsBooks() throws Exception {
mvc.perform(get("/api/books"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("The Pragmatic Programmer"));
}
@Test
void rejectsABlankTitle() throws Exception {
mvc.perform(post("/api/books").contentType("application/json")
.content("{\"title\":\"\",\"author\":\"X\",\"year\":-5}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.title").exists());
}
}
./mvnw test reports Tests run: 3, Failures: 0, Errors: 0 — the two above plus the context test the generator wrote — and no port is ever opened.
The word doing the work is slice. @WebMvcTest starts the web layer and the controller you name, and deliberately leaves out the rest of the application: no database, no services, none of the beans an API accumulates. That is what makes it quick enough to run on every save, and it is also its constraint — anything your controller depends on has to be supplied by the test rather than found in the context. For a controller that holds its own map, as this one does, there is nothing to supply.
The second test is the one worth copying. It asserts the failure, and specifically that errors.title exists, which means the handler from the previous section cannot be deleted by a future refactor without a test going red.
Warning
On Spring Boot 4 the import is
org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest. Every tutorial written for Boot 3 showsorg.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest, and that package no longer exists — the annotation now ships inspring-boot-webmvc-test. If your IDE cannot resolve@WebMvcTest, this is why.
That is the whole API: four endpoints, one error handler, two tests, and — importantly — a caller who is told what went wrong.
Frequently asked questions
- What is the difference between @Controller and @RestController?
- @RestController is @Controller plus @ResponseBody applied to every method in the class. With plain @Controller, a returned String is treated as a view name to render; with @RestController the returned object is serialised into the response body instead. For an API you want @RestController.
- Do I need to write any code to turn my object into JSON?
- No. The web starter brings Jackson, and Spring Boot configures it. Return a record, a class or a List and you get JSON. You only write conversion code when you want to change the output — a different field name, a date format, a field left out.
- Why does my 404 message disappear from the response?
- Because Spring Boot's default error body — timestamp, status, error, path — leaves the message out unless you ask for it, so the text you passed to ResponseStatusException is dropped. On Spring Boot 4, spring.web.error.include-message=always adds a message member to that body (the Boot 3 name, server.error.include-message, is now ignored). It does that for every error, though, including a failed type conversion whose message names java.lang.Long. Setting spring.mvc.problemdetails.enabled=true instead switches to an RFC 9457 problem document, whose detail member carries your message through.
- Is ProblemDetail enough for validation errors?
- No, and this surprises people. With ProblemDetail enabled a failed @Valid still returns only "Invalid request content." with no field named, and spring.web.error.include-binding-errors=always does not change it. That property (Spring Boot 4's name for server.error.include-binding-errors, which is now ignored) adds an errors array only when ProblemDetail is off, and each entry also carries the rejected value and Spring's internal message codes. To get field errors in a problem document, write a handler that reads the binding result and attaches them yourself.
- Do I need a running server to test a controller?
- No. @WebMvcTest loads the web layer only and gives you a MockMvc that calls your controller directly, with no port opened. It is fast enough to run on every save, and it can assert the failure paths as easily as the happy ones.
References
- Spring Framework: @ResponseBodySpring
- Spring Framework: Error ResponsesSpring
- Spring Boot Reference: Servlet Web ApplicationsSpring
- Spring Boot Reference: Testing Spring Boot ApplicationsSpring
- Spring Framework: MockMvcSpring
- RFC 9457: Problem Details for HTTP APIsIETF
- Jakarta Bean Validation 3.0Jakarta EE
- Spring InitializrSpring