Spring Boot Course · Lesson 4
So far your API only reads. In this lesson you'll make it write: add a book, fetch one by id, replace it, delete it — the four operations behind every resource on the web. Along the way you'll meet @RequestBody, @PathVariable, and ResponseEntity, and you'll learn to say the right thing back with an HTTP status code.
/api/books resource: POST a new book and get 201 Created back, GET /api/books/{id} one book (or 404 if it's gone), PUT to replace it, DELETE to remove it with 204 No Content. You'll drive the whole thing from curl.
Almost everything an API does to a resource is one of four actions, and REST maps each to an HTTP method. This mapping is a convention worth memorising — interviewers ask for it, and Spring's annotations are named after it:
/api/books resource Create → POST /api/books body: the new book → 201 Created
Read → GET /api/books (the whole list) → 200 OK
Read → GET /api/books/{id} (one book) → 200 OK / 404
Update → PUT /api/books/{id} body: replacement → 200 OK / 404
Delete → DELETE /api/books/{id} → 204 No Content / 404 The noun (the resource) stays in the URL; the verb (what you're doing to it) is the HTTP method. You never put /createBook or /deleteBook in the path — the method already says that.
Lesson 3's BookService returned a hard-coded List.of(...) — fine for reading, useless for writing (it's immutable, and there's nowhere to store a new book). To support CRUD the service needs a real, mutable store. Until Lesson 5 brings in a database, an in-memory map keyed by id does the job:
src/main/java/com/example/library/book/BookService.java
package com.example.library.book;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class BookService {
// Our "database" for now — a thread-safe map from id → book.
private final Map<UUID, Book> store = new ConcurrentHashMap<>();
public BookService() {
// Seed the same three books as before so GET still has data.
seed(UUID.fromString("11111111-1111-1111-1111-111111111111"), "Clean Code", "Robert C. Martin", 2008);
seed(UUID.fromString("22222222-2222-2222-2222-222222222222"), "Effective Java", "Joshua Bloch", 2018);
seed(UUID.fromString("33333333-3333-3333-3333-333333333333"), "The Pragmatic Programmer", "Hunt & Thomas", 1999);
}
private void seed(UUID id, String title, String author, int year) {
store.put(id, new Book(id, title, author, year));
}
public List<Book> findAll() {
return List.copyOf(store.values());
}
public Optional<Book> findById(UUID id) {
return Optional.ofNullable(store.get(id));
}
public Book create(Book incoming) {
UUID id = UUID.randomUUID(); // the server assigns the id
Book saved = new Book(id, incoming.title(), incoming.author(), incoming.year());
store.put(id, saved);
return saved;
}
public Optional<Book> replace(UUID id, Book incoming) {
if (!store.containsKey(id)) {
return Optional.empty(); // nothing to replace
}
Book updated = new Book(id, incoming.title(), incoming.author(), incoming.year());
store.put(id, updated);
return Optional.of(updated);
}
public boolean delete(UUID id) {
return store.remove(id) != null; // true if something was there
}
} Optional instead of returning null or throwing?
"Book not found" isn't an error in the service's world — it's a perfectly normal answer to "do you have id X?". Returning an Optional<Book> makes that answer explicit and forces the controller to decide what it means over HTTP (here: a 404). The service stays free of web concerns — it doesn't know what a status code is. We'll graduate to throwing a custom exception and handling it globally in Lesson 7; Optional is the clean first step.
create we call UUID.randomUUID() and build a fresh Book, deliberately ignoring any id the client sent. Identity is the server's job — a client must not get to pick (or overwrite) a record's id. Accepting a full Book on the way in is a little loose for exactly this reason; the proper fix is a dedicated input type with no id field (a DTO), which is Lesson 6. For now, ignoring it keeps the code small and the lesson focused.
@PathVariable
To act on a single book you need to name it in the URL: GET /api/books/22222222-.... The {id} placeholder in the mapping captures that segment, and @PathVariable binds it to a method parameter — Spring even converts the text to a UUID for you:
@GetMapping("/{id}") // GET /api/books/{id}
public ResponseEntity<Book> byId(@PathVariable UUID id) {
return books.findById(id)
.map(ResponseEntity::ok) // found → 200 OK + body
.orElse(ResponseEntity.notFound().build()); // missing → 404
}
This replaces Lesson 3's throwaway /featured endpoint with something genuinely useful: fetch any book by its id. And it introduces the pattern the rest of this lesson leans on — turn an Optional into either a 200 with a body or a 404.
ResponseEntity?
Until now your methods returned a Book or a List<Book> and Spring assumed 200 OK. ResponseEntity lets you control the whole HTTP response — status code, headers, and body — instead of just the body. ResponseEntity.ok(book) is 200 + book; ResponseEntity.notFound().build() is a 404 with no body. You reach for it whenever the status isn't always the default.
@PostMapping and @RequestBody
A GET carries no body; a POST does — the JSON for the new book. @RequestBody tells Spring to take the request body and deserialize it (Jackson again, in reverse) into a Book:
@PostMapping // POST /api/books
public ResponseEntity<Book> create(@RequestBody Book incoming) {
Book saved = books.create(incoming);
URI location = URI.create("/api/books/" + saved.id());
return ResponseEntity.created(location).body(saved); // 201 + Location header
}
Creating a resource isn't a 200 — it's a 201 Created, and the convention is to include a Location header pointing at the new resource's URL. ResponseEntity.created(location) sets both the 201 status and that header in one call; .body(saved) returns the stored book — now carrying its server-assigned id so the client knows where it landed.
@PutMapping, delete with @DeleteMapping PUT targets one book by id and replaces it wholesale; DELETE removes it. Both reuse the find-or-404 shape:
@PutMapping("/{id}") // PUT /api/books/{id}
public ResponseEntity<Book> replace(@PathVariable UUID id, @RequestBody Book incoming) {
return books.replace(id, incoming)
.map(ResponseEntity::ok) // replaced → 200 OK + book
.orElse(ResponseEntity.notFound().build()); // unknown id → 404
}
@DeleteMapping("/{id}") // DELETE /api/books/{id}
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return books.delete(id)
? ResponseEntity.noContent().build() // deleted → 204 No Content
: ResponseEntity.notFound().build(); // wasn't there → 404
}
Note ResponseEntity<Void> on delete: there's no body to send back, so 204 No Content is the honest status — "done, and there's nothing to show you." Returning 200 with an empty body would work, but 204 says it more precisely.
GET, PUT, and DELETE are idempotent: replace a book with the same data twice and nothing changes; delete it twice and it's still just gone (the second call is a harmless 404). POST is not — post the same book twice and you've created two books with two different ids. This is exactly why create is POST (you want a new resource each time) and replace is PUT (you're pinning a known id to a known state).
The four operations plus the two reads, assembled. Every method is a thin translation: take the HTTP request, call one service method, map the result to a status code.
src/main/java/com/example/library/book/BookController.java
package com.example.library.book;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@GetMapping // GET /api/books
public List<Book> all() {
return books.findAll();
}
@GetMapping("/{id}") // GET /api/books/{id}
public ResponseEntity<Book> byId(@PathVariable UUID id) {
return books.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping // POST /api/books
public ResponseEntity<Book> create(@RequestBody Book incoming) {
Book saved = books.create(incoming);
URI location = URI.create("/api/books/" + saved.id());
return ResponseEntity.created(location).body(saved);
}
@PutMapping("/{id}") // PUT /api/books/{id}
public ResponseEntity<Book> replace(@PathVariable UUID id, @RequestBody Book incoming) {
return books.replace(id, incoming)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}") // DELETE /api/books/{id}
public ResponseEntity<Void> delete(@PathVariable UUID id) {
return books.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
} curl
Restart the app (./mvnw spring-boot:run) and exercise the full lifecycle. The -i flag prints the status line and headers so you can see the codes you worked for:
# Create a book — note the body is JSON, and we set the content type.
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Refactoring","author":"Martin Fowler","year":2018}'
# → HTTP/1.1 201 Created
# Location: /api/books/<the-new-uuid>
# { "id":"<the-new-uuid>", "title":"Refactoring", ... }
# Read it back by the id from the response above.
curl -i http://localhost:8080/api/books/<the-new-uuid> # → 200 OK
# Replace it.
curl -i -X PUT http://localhost:8080/api/books/<the-new-uuid> \
-H "Content-Type: application/json" \
-d '{"title":"Refactoring, 2nd ed.","author":"Martin Fowler","year":2018}' # → 200 OK
# Delete it.
curl -i -X DELETE http://localhost:8080/api/books/<the-new-uuid> # → 204 No Content
# Ask for it again — it's gone.
curl -i http://localhost:8080/api/books/<the-new-uuid> # → 404 Not Found That round-trip — create, read, replace, delete, confirm-gone — is the heartbeat of every REST API. You just built one by hand.
415 Unsupported Media Type on POST/PUT? You forgot -H "Content-Type: application/json". Without it Spring won't run the JSON converter to read your @RequestBody.400 Bad Request with a parse error? The JSON body is malformed (a stray comma, single quotes inside, an unquoted key). Validate the payload you're sending.POST returns 200, not 201? You returned the Book directly instead of ResponseEntity.created(...). Only ResponseEntity lets you set the status and Location header.id in your POST body shows up changed in the response? Correct — the server assigns its own id and ignores yours. That's deliberate (see Step 1).Retrieval beats re-reading. Answer from memory before clicking.
@RequestBody do?DELETE /api/books/{id} should return which status?POST used for create while PUT is used for replace? Primary source: the MDN reference on
HTTP request methods
and
HTTP response status codes
— skim the entries for POST, PUT, DELETE and for 200, 201, 204, 404. These are the web's own definitions of the verbs and codes you just wired up, and knowing them cold is the difference between guessing a status and choosing one.
New vocabulary from this lesson lives in the Glossary — your quick-reference for every term we use.