Spring Boot Course · Lesson 4

Full CRUD: POST, PUT, DELETE & Status Codes

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.

The win A complete /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.

CRUD is just four HTTP verbs

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:

CRUD ↔ HTTP, for the /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.

Step 1 — Give the service a place to put things

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
    }
}
Why 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.
The server assigns the id — and why the request body's id is ignored In 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.

Step 2 — Read one by id with @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.

What is 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.

Step 3 — Create with @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.

Step 4 — Update with @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.

Interview gold: which verbs are idempotent? An operation is idempotent if doing it once or ten times leaves the server in the same state. 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 full controller

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();
    }
}

Step 5 — Drive it from 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.

If it doesn't work

Check yourself

Retrieval beats re-reading. Answer from memory before clicking.

Which HTTP method + path creates a new book?
What does @RequestBody do?
A successful DELETE /api/books/{id} should return which status?
Why is POST used for create while PUT is used for replace?

Read this next

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.