Build a Book Collection REST API

Build a Spring Boot REST API with full CRUD operations and H2 database.

Introduction

30 Second Summary

Every app you use keeps a collection of something. Your music library, your shopping cart, your bookmarks. Behind the scenes, a REST API handles the create, read, update, and delete operations that make those collections work.

In this project, you will build a Book Collection REST API using Spring Boot. You will send real HTTP requests to add books, retrieve them, update details, and delete entries from an in-memory database.

What You'll Build

You will run a live API on your machine, fire off requests from your terminal, and watch books appear in a database console you can browse visually.

By the end of this project, you'll have:

  • A running REST API on localhost:8080 that accepts JSON requests and responds with book data you can see and verify.
  • Full CRUD operations where you POST a new book, GET it back by ID, PUT updated details, and DELETE it with confirmation.
  • A built-in H2 database console where you visually browse the books table and see your data appear in real time.
  • Secret Mission: Add a search endpoint that finds books by author name.

Are there any prerequisites?

You should be comfortable with Java fundamentals (classes, methods, annotations) and have a basic understanding of HTTP (what a URL is, what GET and POST mean). No prior Spring Boot experience is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Spring Boot Project

Before you can write any API code, you need a working project skeleton with the right dependencies wired up.

Spring Initializr generates an entire Maven project structure with all your dependencies configured in seconds. In this step, you will generate your project, open it in VS Code, and confirm it starts successfully.

In this step, get ready to:
  • Generate a Spring Boot project with the correct dependencies using Spring Initializr.
  • Run the application and confirm it starts on port 8080.
Generate the project on Spring Initializr

Spring Initializr is a web tool that generates ready-to-run Spring Boot projects. You fill out a form, pick your dependencies, and download a zip file containing the full project structure.

  • Open https://start.spring.io in your browser.
  • Under Project, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 4.1.0.
  • In the Group field, enter com.example.
  • In the Artifact field, enter bookcollection.
  • Under Java (the version selector near Packaging), select 17.

What are Group and Artifact?

Group is the unique namespace for your organization (like a domain name in reverse). Artifact is the name of your specific project. Together they form your project's identity in the Maven ecosystem.

  • Click ADD DEPENDENCIES.
  • Search for Spring Web and select it.
  • Search for Spring Data JPA and select it.
  • Search for H2 Database and select it.
  • Click Generate to download the project zip file.
  • Extract the downloaded bookcollection.zip file to your Desktop.

What do these dependencies do?

Spring Web provides the framework for building REST API endpoints that respond with JSON.

Spring Data JPA handles all the database communication so you do not have to write SQL manually. H2 Database is a lightweight in-memory database that requires zero installation.

Open the project in VS Code and run it
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the bookcollection folder you just extracted, then click Open.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).

You should see the project files in the Explorer sidebar, including pom.xml and a src folder.

The generated project includes a Maven wrapper (mvnw) so you can run the application without installing Maven globally.

  • Start the application by running this command in your VS Code terminal:

macOS

./mvnw spring-boot:run

Windows

.\mvnw spring-boot:run

What does this command do?

mvnw is the Maven wrapper script included in the project. It downloads the correct Maven version automatically, then runs the spring-boot:run goal to compile and start your application.

The first run takes a minute or two because Maven downloads all the project dependencies. Subsequent runs are much faster.

Before you look at the output, what do you think the console will show if the application started correctly?

You should see output ending with lines containing Started BookcollectionApplication and Tomcat initialized with port 8080.

Not seeing the started message?

  • If you see a permission denied error on macOS, run chmod +x mvnw first to make the wrapper script executable, then try again.
  • If you see JAVA_HOME is not set, make sure JDK 17 is installed and your JAVA_HOME environment variable points to it.
  • If port 8080 is already in use, another application is occupying it. Stop that process or close the other terminal before trying again.

Help me troubleshoot why my Spring Boot application won't start.

  • Stop the running server by pressing Ctrl+C in the terminal.

Your project skeleton is ready and proven to run. Next up, you will create the Book entity and see it appear as a table in the database.

Create the Book Entity and See It in the Database

Your Spring Boot app starts, but it has no idea what data it will manage. Before you can build any API endpoints, you need a data model that tells Spring Boot the shape of the objects you want to store.

In this step, you will create a Book entity class that maps directly to a database table. The moment you run the app, Spring Data JPA auto-generates the table for you. You will see it with your own eyes in the H2 web console.

In this step, get ready to:
  • Create a Book entity class with JPA annotations.
  • Configure the H2 in-memory database and web console.
  • Verify the auto-generated BOOK table in the H2 console.
Create the Book entity class

A JPA entity is a plain Java class annotated with @Entity. Spring Boot reads this annotation at startup and creates a matching database table automatically.

  • In the VS Code Explorer sidebar, right-click the src/main/java/com/example/bookcollection folder.
  • Select New File and name it Book.java.
  • Paste the following code into Book.java to define the entity structure:
package com.example.bookcollection;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Book {

    // Primary key, auto-incremented by the database
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String author;
    private String isbn;

    // JPA requires a no-args constructor
    protected Book() {
    }

    public Book(String title, String author, String isbn) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
    }
}

What does this code do?

@Entity tells Spring Boot this class maps to a database table. The table name defaults to the class name (BOOK).

@Id marks the primary key field. @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment the ID for each new book.

The protected no-args constructor is required by the JPA specification. The public constructor is what your code will use when creating new books.

  • Now add the getters and setters below the public constructor, above the closing } brace:
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }

Why do entities need getters and setters?

Spring Data JPA uses these methods to read and write field values when converting between Java objects and database rows. Without them, the framework cannot populate your Book objects from query results or persist changes back to the database.

  • Save Book.java.
  • Confirm VS Code shows no red underlines in the file.

Seeing red underlines on the imports?

Make sure your imports use jakarta.persistence, not javax.persistence. Spring Boot 4.1.0 uses the Jakarta namespace.

Also confirm Book.java is in the same package folder as BookcollectionApplication.java. help me fix red underlines in my Book.java entity

✔️ Awesome, I've got everything!

Great. Make sure you have saved Book.java before moving on.

ⓧ I'd like to double check the full code

Your Book.java has two sections you built above. Scroll through the file and confirm they appear in this order:

  • Package declaration, imports, @Entity class with fields (id, title, author, isbn) and both constructors.
  • Getters and setters for all four fields, followed by the closing } brace.
  • Verify Spring Boot recognizes your entity by running the application:
.\mvnw spring-boot:run

You should see Hibernate initialization output in the terminal, followed by "Started BookcollectionApplication". This confirms Spring Boot detected your Book entity and set up the database.

  • Press Ctrl+C in the terminal to stop the server.
Configure H2 console access

Spring Boot auto-created the database table, but you have no way to see it visually yet. The H2 database includes a built-in web console for browsing tables, but it is disabled by default.

  • In the VS Code Explorer sidebar, expand src/main/resources and open application.properties.
  • Paste the following configuration into the file:
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

What does each property do?

  • spring.h2.console.enabled=true turns on the H2 web console at /h2-console.
  • spring.datasource.url sets a fixed database name so the console can connect to it.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to create or update tables automatically.
  • spring.jpa.show-sql=true prints every SQL statement to the terminal so you can see exactly what Hibernate does.
  • Save application.properties.

File looks empty or doesn't exist?

Spring Initializr creates application.properties as an empty file inside src/main/resources/. If you cannot find it, right-click the resources folder and create a new file named application.properties. help me find or create application.properties

✔️ Awesome, I've got everything!

Great. Make sure application.properties is saved before running the app.

ⓧ I'd like to double check the full code

spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Run the app and verify the BOOK table in the H2 console

With the entity and properties in place, Spring Boot will create the BOOK table and expose a web console where you can see it.

  • Start the application by running:
.\mvnw spring-boot:run

Watch the terminal output. You should see SQL statements like create table book printed, followed by "Started BookcollectionApplication".

Before you open the H2 console, what do you expect to find in the database? You have not added any books yet, so think about whether the table itself exists and whether it has any rows.

  • Open your browser and go to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:testdb.
  • In the User Name field, enter sa.
  • Leave the Password field empty.
  • Click Connect.

You should see the BOOK table listed in the left panel. Click on it to see columns: ID, TITLE, AUTHOR, and ISBN. The table is empty because you have not added any books yet.

Don't see the BOOK table?

Double-check the JDBC URL is exactly jdbc:h2:mem:testdb. The default value in the form may be different.

Make sure the application is still running in your terminal. The H2 in-memory database only exists while the server is active. help me connect to the H2 console

Your data model is live. Spring Boot read your Book entity and created a real database table without a single line of SQL. Next up, you will build a REST controller that exposes this data over HTTP so you can add and retrieve books with real requests.

Build the REST Controller and Hit Your First Endpoint

In the previous step, you created the Book entity and confirmed Spring Boot auto-generated the BOOK table in the H2 console. Now it is time to expose that data over HTTP so external clients can request it.

A REST API needs two things to serve data: a repository that talks to the database, and a controller that handles incoming HTTP requests. You will create both in this step and hit your first live endpoint.

In this step, get ready to:
  • Create a repository interface that gives you database access for free.
  • Build a REST controller that responds to GET requests at /api/books.
  • Hit your first endpoint with curl and see a live JSON response.
Create the BookRepository interface

Spring Data JPA eliminates boilerplate database code. Instead of writing SQL queries by hand, you define a Java interface that extends JpaRepository and Spring generates the implementation at runtime.

  • In the VS Code Explorer sidebar, right-click the src/main/java/com/example/bookcollection folder.
  • Select New File and name it BookRepository.java.
  • Paste the following code into the new file:
package com.example.bookcollection;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthorContainingIgnoreCase(String author);
}

What does this code do?

JpaRepository<Book, Long> tells Spring this repository manages Book entities whose primary key is a Long. Spring auto-generates methods like findAll(), findById(), save(), and delete() for you.

The findByAuthorContainingIgnoreCase method is a derived query. Spring reads the method name and generates a case-insensitive search query automatically. You will use it later for search functionality.

  • Save BookRepository.java.

✔️ Awesome, I've got everything!

Great. You now have database access wired up with zero SQL.

ⓧ I'd like to double check the full code

package com.example.bookcollection;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthorContainingIgnoreCase(String author);
}
Create the BookController class

The controller is the layer that listens for HTTP requests and returns responses. It delegates the actual data work to the repository you just created.

  • Right-click the same src/main/java/com/example/bookcollection folder in the Explorer sidebar.
  • Select New File and name it BookController.java.
  • Paste the following class shell with its constructor into the file:
package com.example.bookcollection;

import java.util.List;

import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookRepository bookRepository;

    public BookController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON responses directly.
  • @RequestMapping("/api/books") sets the base URL path for all endpoints in this controller.
  • The constructor accepts a BookRepository parameter. Spring automatically injects the repository instance at startup (constructor injection).
  • The extra imports at the top are for HTTP methods you will add in the next step.
  • Add this method inside the class, below the constructor and before the closing }:
    @GetMapping
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

What does this method do?

@GetMapping maps HTTP GET requests to this method. Because the class already has @RequestMapping("/api/books"), a GET request to /api/books triggers getAllBooks(). It calls bookRepository.findAll() and Spring automatically converts the result to JSON.

  • Save BookController.java.

✔️ Awesome, I've got everything!

Great. Your controller is ready to handle requests.

ⓧ I'd like to double check the full code

Your BookController.java should contain both chunks in order: the class shell with imports and constructor (from the first code block), followed by the getAllBooks() method (from the second code block) placed inside the class before the closing brace. Scroll up and confirm both are present.

Run the application and test with curl

Time to see your endpoint respond to a real HTTP request.

  • If the server is still running from the previous step, press Ctrl+C in the terminal to stop it.
  • Start the application by running this command:
.\mvnw spring-boot:run

Wait until you see Started BookcollectionApplication in the console output.

Before you test the endpoint, what do you think you will get back when you request all books from an empty database?

  • Open a second terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • Test your endpoint by running this command:
curl http://localhost:8080/api/books

You should see [] returned. That is an empty JSON array, confirming your API is live and responding. No books exist yet because you have not added any.

Not seeing the empty array?

  • Make sure the first terminal still shows Started BookcollectionApplication. If the server crashed, check for red error text mentioning a missing class or typo.
  • Confirm you are running curl in a separate terminal, not the one running the server.
  • Double-check that BookRepository.java and BookController.java are in the same package folder as Book.java. Spring only scans the package declared in your main application class.

Why is my /api/books endpoint returning an error instead of an empty array?

You have a live REST API that responds to HTTP requests with JSON. Next up, you will add POST, PUT, and DELETE methods so you can actually create, update, and remove books.

Add Create, Update, and Delete Operations

Your API can list books, but listing an empty shelf is not very useful. A real book collection needs the ability to add new books, change their details, and remove entries you no longer want.

In this step, you will add POST, GET-by-ID, PUT, and DELETE endpoints to your controller. By the end, you will have a complete CRUD API. You will also discover a gap in how the API handles errors that the next step will fix.

In this step, get ready to:
  • Add POST and GET-by-ID endpoints to create and retrieve individual books.
  • Add PUT and DELETE endpoints to update and remove books.
  • Test the complete CRUD flow and observe the unhandled error when requesting a non-existent book.
Add the create and get-by-ID methods

The POST endpoint accepts a JSON book and saves it to the database. The GET-by-ID endpoint retrieves a single book by its primary key. Both need a way to signal when a requested book does not exist, so you will create a simple exception class first.

  • Press Ctrl+C in the terminal where your application is running to stop the server.
  • Create a new file at src/main/java/com/example/bookcollection/BookNotFoundException.java by pasting the following code:
package com.example.bookcollection;

// Custom exception thrown when a book ID does not exist
public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        super("Book not found with id: " + id);
    }
}

What does this class do?

BookNotFoundException extends RuntimeException, which means Spring will treat it as an unhandled server error for now.

The constructor builds a descriptive message like "Book not found with id: 5" so you can identify exactly which ID was missing. You will add proper handling for this exception in the next step.

  • Save BookNotFoundException.java.
  • Now open BookController.java and find the closing brace of the getAllBooks() method.
  • Add the following two methods directly below it:
    // Retrieve a single book by its database ID
    @GetMapping("/{id}")
    public Book getBookById(@PathVariable Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }

    // Accept a JSON book in the request body and save it
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Book createBook(@RequestBody Book book) {
        return bookRepository.save(book);
    }

What do these methods do?

  • getBookById uses @PathVariable to extract the ID from the URL path. If the repository returns empty, it throws your new exception.
  • createBook uses @RequestBody to deserialize incoming JSON into a Book object automatically. The @ResponseStatus(HttpStatus.CREATED) annotation makes the response return a 201 status instead of the default 200.
  • Save BookController.java.
  • Restart the application by running:
.\mvnw spring-boot:run
  • Wait for the "Started BookcollectionApplication" message, then open a second terminal in VS Code by clicking Terminal in the top menu, then New Terminal.
  • Test the POST endpoint by running this command in the new terminal:
curl -X POST -H "Content-Type: application/json" -d "{\"title\":\"Spring in Action\",\"author\":\"Craig Walls\",\"isbn\":\"9781617297571\"}" http://localhost:8080/api/books

You should see a JSON response containing your book with an id field added automatically by the database.

Not seeing a JSON response?

  • Make sure the application finished starting. Look for "Started BookcollectionApplication" in the first terminal before sending the request.
  • Check that the curl command uses the correct port 8080.
  • If you see a compilation error, verify that BookNotFoundException.java is saved in the same package folder as BookController.java.

Help me debug my POST request to /api/books.

Add the update and delete methods

With create and read in place, your API still cannot modify or remove books. The PUT endpoint replaces a book's fields, and the DELETE endpoint removes it entirely.

  • Switch back to BookController.java and add the following two methods below createBook:
    // Update an existing book's title, author, and isbn
    @PutMapping("/{id}")
    public Book updateBook(@PathVariable Long id, @RequestBody Book bookDetails) {
        Book book = bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
        book.setTitle(bookDetails.getTitle());
        book.setAuthor(bookDetails.getAuthor());
        book.setIsbn(bookDetails.getIsbn());
        return bookRepository.save(book);
    }

    // Delete a book by ID and return 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        Book book = bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
        bookRepository.delete(book);
        return ResponseEntity.noContent().build();
    }

What do these methods do?

  • updateBook first loads the existing book from the database. It then copies the new values from the request body onto the existing entity and saves it back.
  • deleteBook loads the book to confirm it exists, then deletes it. It returns ResponseEntity.noContent().build() which sends a 204 status with no body, the standard response for a successful delete.
  • Save BookController.java.
  • Stop the running server with Ctrl+C in the first terminal, then restart it:
.\mvnw spring-boot:run
  • In the second terminal, update the book you created earlier by running:
curl -X PUT -H "Content-Type: application/json" -d "{\"title\":\"Spring in Action 6th Edition\",\"author\":\"Craig Walls\",\"isbn\":\"9781617297571\"}" http://localhost:8080/api/books/1

You should see the book returned with the updated title.

  • Delete the book by running:
curl -X DELETE http://localhost:8080/api/books/1
  • Confirm the book is gone by fetching all books:
curl http://localhost:8080/api/books

You should see an empty array [] again, confirming the delete worked.

PUT returning an error?

  • The H2 database resets each time you restart the server. If you restarted before testing PUT, the book with ID 1 no longer exists. POST a new book first, then use the ID from that response.
  • Check that the URL ends with the correct ID number (e.g. /api/books/1).

Help me debug my PUT or DELETE request.

Observe the error handling gap

You now have a fully working CRUD API. But what happens when someone requests a book that does not exist?

  • Before you run the next command, predict what you think the API will return for a book ID that has never been created.
  • Test it by running:
curl http://localhost:8080/api/books/999

You see a 500 Internal Server Error with a generic JSON error body. That is confusing for anyone consuming your API.

Why is this a 500 and not a 404?

Your BookNotFoundException is being thrown, but Spring does not know what HTTP status to map it to. Without an explicit exception handler, Spring treats any unhandled RuntimeException as a server error and returns 500.

The next step fixes this by adding a global exception handler that catches BookNotFoundException and returns a clean 404 with a helpful message.

✔️ Awesome, I've got everything!

Great. Double check you have saved both BookNotFoundException.java and BookController.java.

ⓧ I'd like to double check the full code

Your BookController.java should now contain these methods in this order:

  • getAllBooks() with @GetMapping.
  • getBookById() with @GetMapping("/{id}").
  • createBook() with @PostMapping.
  • updateBook() with @PutMapping("/{id}").
  • deleteBook() with @DeleteMapping("/{id}").

Here is the full BookNotFoundException.java for reference:

package com.example.bookcollection;

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        super("Book not found with id: " + id);
    }
}

Scroll up and confirm each method in BookController.java matches what you added in the two chunks above. The imports at the top of the file should remain unchanged from the previous step.

Your API now supports every CRUD operation. Next up, you will fix that ugly 500 error by adding proper exception handling that returns a clean 404 with a helpful message.

Add Proper Error Handling

In the previous step, you saw what happens when you request a book that doesn't exist. The API returned a 500 Internal Server Error with a confusing stack trace instead of a helpful message.

In this step, you'll fix that with two new classes. A custom exception describes the problem, and a global handler catches it and returns a clean 404 response with a readable JSON body. This is the pattern interviewers look for in production Spring Boot applications.

In this step, get ready to:
  • Create a custom exception class for missing books.
  • Create a global exception handler that returns a 404 JSON response.
  • Verify the clean error response with a curl request.
Create the custom exception class

Your controller already references BookNotFoundException in its methods. The class doesn't exist yet, which is why requests for missing books fail. Let's create it.

  • Create a new file at src/main/java/com/example/bookcollection/BookNotFoundException.java and add the following code:
package com.example.bookcollection;

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        super("Book not found with id: " + id);
    }
}

What does this code do?

  • BookNotFoundException extends RuntimeException, making it an unchecked exception that Spring can intercept without polluting method signatures with throws declarations.
  • The constructor takes the book's id and builds a human-readable message like "Book not found with id: 5". This message will appear in the JSON error response.
  • Save BookNotFoundException.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcollection;

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        super("Book not found with id: " + id);
    }
}
Create the global exception handler

The exception class describes the problem. Now you need a handler that catches it and converts it into a proper HTTP 404 response with a JSON body.

  • Create a new file at src/main/java/com/example/bookcollection/GlobalExceptionHandler.java and add the following code:
package com.example.bookcollection;

import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Map.of("message", ex.getMessage()));
    }
}

What does this code do?

  • @RestControllerAdvice tells Spring to scan this class for exception-handling methods that apply across all controllers.
  • @ExceptionHandler(BookNotFoundException.class) marks the method that runs whenever a BookNotFoundException is thrown anywhere in the application.
  • The method returns a ResponseEntity with HTTP status 404 and a JSON body containing the exception's message.
  • Save GlobalExceptionHandler.java.
  • Restart the application by pressing Ctrl+C in the terminal to stop it, then running:
.\mvnw spring-boot:run

The console should print "Started BookcollectionApplication" with no errors.

Seeing a compilation error?

  • Check that both BookNotFoundException.java and GlobalExceptionHandler.java are in the src/main/java/com/example/bookcollection/ directory.
  • Verify the package declaration at the top of each file says package com.example.bookcollection; exactly.
  • Make sure BookNotFoundException in the handler's @ExceptionHandler annotation matches the class name exactly (case-sensitive).

Still stuck? Why won't my Spring Boot application compile after adding the exception handler?

✔️ Awesome, I've got everything!

Great. Make sure your application is running before the next step.

ⓧ I'd like to double check the full code

package com.example.bookcollection;

import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Map.of("message", ex.getMessage()));
    }
}
Test the clean 404 response

Both pieces are in place. The controller throws BookNotFoundException when a book ID doesn't exist, and the handler converts that into a 404 with a JSON message.

Before you run the next command, predict: what HTTP status code and body do you expect when you request a book with ID 999?

  • Open a new terminal in VS Code and test the error response by running:
curl -v http://localhost:8080/api/books/999

You should see a 404 Not Found status in the response headers and a JSON body:

{"message":"Book not found with id: 999"}

What changed from the 500 error?

Without the handler, Spring Boot's default error mechanism caught the unhandled exception and returned a generic 500 response. Now your GlobalExceptionHandler intercepts BookNotFoundException before the default kicks in, returning a specific 404 status and a message the client can act on.

This same pattern scales to handle validation errors, authentication failures, and any other custom exception your API might throw.

Still seeing a 500 error?

  • Make sure you restarted the application after creating both files. Changes require a restart.
  • Check that GlobalExceptionHandler has the @RestControllerAdvice annotation at the class level.
  • Verify the @ExceptionHandler annotation targets BookNotFoundException.class specifically.

Need more help? Why is my Spring Boot API still returning 500 instead of 404 after adding the exception handler?

Secret mission

Add Search by Author

Your API handles basic CRUD, but real-world APIs need search capabilities. Can you add an endpoint that finds books by author name using Spring Data JPA's derived query feature?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs. The H2 in-memory database disappears automatically when you stop the server.

Resources you used:

  • Embedded Tomcat server (runs locally, stops when you press Ctrl+C).
  • H2 in-memory database (automatically deleted when the application stops).
  • Project files in the bookcollection folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your API.

  • Leave the terminal open with mvnw spring-boot:run to keep sending requests to your API.
  • Your H2 database keeps your book data as long as the server is running.
  • Open http://localhost:8080/h2-console any time to inspect your stored books.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your code stays on disk so you can restart any time.

  • Switch to the terminal where the server is running.
  • Press Ctrl+C to stop the Spring Boot application.
  • Your book data in the H2 database is gone (it only lives in memory while the server runs).
  • Your project code in the bookcollection folder remains intact. Run .\mvnw spring-boot:run again whenever you want to pick back up.

Delete - I don't want to use this again

Remove all project files and start fresh. Since everything is local, this is a clean removal with no lingering resources.

  • Switch to the terminal where the server is running and press Ctrl+C to stop it.
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the project folder. On macOS/Linux, run:
rm -rf bookcollection
  • On Windows (PowerShell), run:
Remove-Item -Recurse -Force bookcollection
  • Confirm the folder is gone by running ls. You should no longer see bookcollection in the output.

Nice Work!

Nice Work!

You just built a fully functional REST API from scratch with Spring Boot 4.1.0. Your API handles real HTTP requests, persists data, and returns clean error responses.

You've learned how to:

  • Built a complete REST API that handles GET, POST, PUT, and DELETE requests using @RestController and @RequestMapping.
  • Used Spring Data JPA with JpaRepository to handle all database operations without writing a single line of SQL, and inspected your data visually through the H2 web console.
  • Added global exception handling with @RestControllerAdvice to return clean 404 error responses instead of confusing 500 errors.
  • Secret Mission: Added a search-by-author endpoint using Spring Data JPA derived query methods to filter books without writing custom SQL.

Ready to quiz yourself?