Build a REST API with Spring Boot

Build a Book Catalog REST API with full CRUD and error handling.

Introduction

30 Second Summary

Every time you check the weather on your phone, your app is quietly sending a request to a server and getting data back. That invisible conversation is powered by an API.

In this project, you will build a Book Catalog REST API using Spring Boot. You will create endpoints that accept HTTP requests and return JSON responses, just like the APIs behind real-world applications.

What You'll Build

Picture this: you type a command in your terminal, and your API responds with a neatly formatted JSON object containing the book you just created, complete with a database-generated ID.

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

  • A running REST API you can demo from your terminal. Send a POST request with a title, author, and publication year, and get back the saved book with its generated ID.
  • Full CRUD capability over your book catalog. List all books, fetch one by ID, update its details, or remove it entirely.
  • Professional error handling that returns a clean 404 JSON response with a helpful message when someone requests a book that doesn't exist.
  • Secret Mission: Add input validation so your API rejects books with blank titles or invalid publication years.

Are there any prerequisites?

This project assumes you know basic Java, including classes, methods, and imports. You should already have JDK 21 and IntelliJ IDEA Community Edition installed on your machine.

Before We Start

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

Set Up Your Spring Boot Project

Before you can write any API code, you need a working project skeleton with the right dependencies wired up. Setting up a Java project with the correct Maven configuration, web server, and database driver by hand takes time and is error-prone.

Spring Initializr is a web tool that generates a ready-to-run Spring Boot project for you. In this step, you will generate your project, import it into IntelliJ IDEA, and confirm it starts up.

In this step, get ready to:
  • Generate a Spring Boot project from Spring Initializr with the dependencies your API needs.
  • Import the project into IntelliJ IDEA and configure it.
  • Run the application and confirm it starts on port 8080.
Generate the project at start.spring.io
  • Open your browser and go to https://start.spring.io.
  • In the Project section on the left, select Maven.
  • Set Language to Java.
  • Under Spring Boot, select 4.1.0.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to bookcatalog.
  • Set Java to 21.

What do these settings mean?

Maven is the build tool that manages your project's dependencies and build lifecycle. Spring Boot 4.1.0 is the latest stable release with long-term support.

Group and Artifact are Java naming conventions. The Group is your organization's domain in reverse (com.example), and the Artifact is your project's name (bookcatalog). Together they form the base package for your code: com.example.bookcatalog.

  • Click Add Dependencies on the right side of the page.
  • 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 zip file.

What are these three dependencies?

Spring Web gives your project the ability to handle HTTP requests and serve REST API responses. Spring Data JPA lets you store and retrieve data from a database using Java objects instead of writing SQL. H2 Database is a lightweight, in-memory database that Spring Boot auto-configures with zero setup.

A file named bookcatalog.zip downloads to your computer.

Import into IntelliJ and verify the project structure
  • Extract the bookcatalog.zip file to a folder on your machine.
  • Press the Windows key to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • Click Open on the Welcome Screen (or go to File then Open if a project is already open).
  • Navigate to and select the extracted bookcatalog folder.
  • Click OK.

What is the Trust Project dialog?

IntelliJ shows a Trust and Open Project dialog every time you open a project for the first time. Click Trust Project to let IntelliJ resolve dependencies and enable all features. If you choose Preview in Safe Mode by mistake, Maven import will be disabled and your project will not work correctly.

  • Click Trust Project in the dialog that appears.

IntelliJ detects the Maven pom.xml and begins importing the project. You will see a progress bar at the bottom of the window as it downloads dependencies.

  • Wait for the progress bar to finish before continuing.

Once the import completes, verify you see the standard Maven layout in the Project tool window on the left: pom.xml, src/main/java, and src/main/resources.

Now add a configuration line that will help you see database activity later in the project.

  • In the Project tool window, expand src then main then resources.
  • Double-click application.properties to open it.
  • Add the following line below the existing spring.application.name=bookcatalog line:
spring.jpa.show-sql=true

What does this line do?

The spring.jpa.show-sql=true setting tells Spring Boot to print every SQL query it runs to the console. Once you create database entities in the next step, you will see the exact SQL that JPA generates behind the scenes.

  • Save application.properties by pressing Ctrl+S.

Your application.properties file should now have two lines.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

Your application.properties should look exactly like this:

spring.application.name=bookcatalog
spring.jpa.show-sql=true
Run the application
  • In the Project tool window on the left, expand src then main then java then com.example.bookcatalog.
  • Double-click BookcatalogApplication to open it in the editor.

This is the entry point for your Spring Boot application. Spring Initializr generated this class for you with the @SpringBootApplication annotation, which tells Spring Boot to auto-configure everything.

Before you run it, what do you think you will see in the console output?

  • Click the green play button in the gutter next to the main method.
  • Select Run 'BookcatalogApplication.main()' from the context menu.

The console panel opens at the bottom of IntelliJ. You should see the Spring Boot banner (large ASCII art text) followed by several log lines.

  • Look for a line containing Started BookcatalogApplication near the end of the output.
  • Confirm you also see a line containing Tomcat started on port 8080.

What is happening here?

Spring Boot detected the web dependency and automatically started an embedded Tomcat server on port 8080. It also detected the H2 database on the classpath and auto-configured an in-memory database. You did not write a single line of configuration for either of these.

This is Spring Boot auto-configuration in action. It examines your dependencies and sets up sensible defaults so you can focus on your application logic.

Don't see the Started message?

If you see an error about port 8080 already being in use, another application is occupying that port. Close any other servers or processes running on port 8080 and try again.

If IntelliJ cannot find JDK 21, go to File then Project Structure then Project, and set the SDK to your installed JDK 21.

If you see unresolved import errors, IntelliJ may not have finished downloading Maven dependencies. Check the progress bar at the bottom and wait for it to complete.

Still stuck? Help me troubleshoot my Spring Boot startup error.

Your project skeleton is alive. Next up, you will create your first data model and build real API endpoints that return JSON.

Build the Book Entity and First Endpoints

Your Spring Boot project is running, but it has no data and no endpoints. Right now, visiting any URL on localhost:8080 returns a generic error page because nothing is wired up yet.

Every REST API needs three building blocks: a data model that defines what you store, a repository that handles database operations, and a controller that exposes endpoints for clients. In this step, you will create all three and see your first real JSON response in the browser.

In this step, get ready to:
  • Create a Book entity that maps to a database table.
  • Create a BookRepository interface for database operations.
  • Build a BookController with GET and POST endpoints.
Create the Book entity class

Spring Boot uses JPA to map Java classes directly to database tables. A class annotated with @Entity becomes a table, and each field becomes a column.

  • In IntelliJ's Project sidebar, expand src/main/java/com/example/bookcatalog.
  • Right-click the bookcatalog package, select New, then Java Class.
  • Type Book as the name and press Enter.
  • Replace all the generated code in Book.java with the following:
package com.example.bookcatalog;

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

@Entity
public class Book {

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

    private String title;

    private String author;

    private int publicationYear;

    // Required by JPA to create entity instances
    protected Book() {
    }

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

What does this code do?

The @Entity annotation tells JPA to map this class to a database table called book. Each field (title, author, publicationYear) becomes a column.

The @Id and @GeneratedValue annotations mark id as the primary key, which the database auto-increments for each new book. JPA requires a no-args constructor to create entity instances, so the protected Book() constructor is there for that purpose.

  • Add getters and setters for every field by pasting the following code inside the Book class, directly above the closing } brace:
    // Getters and setters for all fields
    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 int getPublicationYear() {
        return publicationYear;
    }

    public void setPublicationYear(int publicationYear) {
        this.publicationYear = publicationYear;
    }

Why does JPA need getters and setters?

JPA uses getters and setters to read and write field values when converting between Java objects and database rows. Without them, the framework cannot populate your Book object from a query result or persist changes back to the database.

✔️ Awesome, I've got everything!

Great. Double check you have saved Book.java.

ⓧ I'd like to double check the full code

Scroll up and confirm both code blocks are present in your Book.java file, in order:

  • The package declaration, imports, @Entity class declaration with four fields, the protected no-args constructor, and the public three-argument constructor.
  • All getters and setters for id, title, author, and publicationYear.

Make sure the file ends with a single closing } brace for the class.

  • Save Book.java with Ctrl+S.
  • In the Run window at the bottom of IntelliJ, click the Rerun button (green circular arrow) to restart the application.

You should see Hibernate: lines in the console output that mention creating the book table. This confirms that JPA detected your @Entity class and auto-created the database table.

Why did a table appear automatically?

You added spring.jpa.show-sql=true to application.properties in the previous step. That setting tells Hibernate to print every SQL statement it runs.

Spring Boot auto-detects the H2 embedded database on the classpath and sets JPA to create-drop mode. This means Hibernate automatically creates tables from your @Entity classes every time the application starts.

Don't see Hibernate SQL output?

  • Make sure application.properties contains spring.jpa.show-sql=true.
  • Check that your Book.java has the @Entity annotation on the class. Without it, JPA does not scan the class.
  • Confirm the import is jakarta.persistence.Entity, not javax.persistence.Entity. Spring Boot 4.1 uses Jakarta EE 11.

Still stuck? Help me debug why Hibernate is not creating my Book table.

Create the BookRepository interface

Spring Data JPA can generate an entire data access layer from a single interface. You declare the interface, and Spring provides the implementation at runtime.

  • Right-click the com.example.bookcatalog package again, select New, then Java Class.
  • Select Interface from the kind dropdown, type BookRepository, and press Enter.
  • Replace all the generated code with the following:
package com.example.bookcatalog;

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

// Spring Data JPA auto-generates all CRUD operations
public interface BookRepository extends JpaRepository<Book, Long> {
}

What does this code do?

By extending JpaRepository<Book, Long>, this interface inherits methods like findAll(), findById(), save(), and delete(). You write zero implementation code. Spring creates the implementation automatically at startup.

  • Save BookRepository.java with Ctrl+S.
Create the BookController with GET and POST endpoints

The controller is the front door of your API. It receives HTTP requests, calls the repository, and returns JSON responses. Spring Boot's @RestController annotation handles all the JSON conversion for you.

  • Right-click the com.example.bookcatalog package, select New, then Java Class.
  • Type BookController and press Enter.
  • Replace all the generated code with the following:
package com.example.bookcatalog;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// Handles all HTTP requests to /api/books
@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookRepository bookRepository;

    // Spring automatically injects the repository
    public BookController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    // Returns every book in the database as JSON
    @GetMapping
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON (not HTML pages).
  • @RequestMapping("/api/books") sets the base URL path. Every endpoint in this controller starts with /api/books.
  • The constructor takes a BookRepository parameter. Spring's dependency injection automatically provides the repository instance.
  • getAllBooks() is mapped to GET /api/books. It calls findAll() on the repository and Spring converts the result list to a JSON array.
  • Save BookController.java with Ctrl+S.
  • Click the Rerun button in the Run window to restart the application.
  • Wait for the Started BookcatalogApplication message to appear in the console.
  • Open your browser and navigate to http://localhost:8080/api/books.

You should see [] in the browser. That is an empty JSON array. The database has no books yet, so the list is empty.

Seeing an error page instead of []?

  • Make sure your @RequestMapping uses /api/books (with a leading slash) and that the browser URL matches exactly.
  • Check the console for a red error stack trace. If you see a compilation error, verify that BookRepository.java is saved and contains the JpaRepository import.
  • Confirm the BookController class is in the same com.example.bookcatalog package as BookcatalogApplication. Spring only scans classes in the application's package and its sub-packages.

Still stuck? Help me figure out why my GET /api/books endpoint returns an error.

Now it is time to add the POST endpoint so you can actually create books. The createBook method receives a JSON book from the request body, saves it to the database, and returns it with an HTTP 201 Created status.

  • In BookController.java, place your cursor on a new line directly above the closing } brace of the class.
  • Paste the following method:
    // Saves the incoming book and returns it with HTTP 201 Created
    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book savedBook = bookRepository.save(book);
        return new ResponseEntity<>(savedBook, HttpStatus.CREATED);
    }

What does this code do?

  • @PostMapping maps this method to POST /api/books.
  • @RequestBody tells Spring to convert the incoming JSON into a Book object automatically.
  • ResponseEntity lets you control the HTTP status code. Instead of the default 200, this returns 201 to signal that a new resource was created.

✔️ Awesome, I've got everything!

Great. Double check you have saved BookController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm both code blocks are present in your BookController.java file, in order:

  • The package declaration, imports, @RestController and @RequestMapping annotations, the bookRepository field, the constructor, and the getAllBooks() method.
  • The createBook() method with @PostMapping, @RequestBody, and the ResponseEntity return.

Make sure the file ends with a single closing } brace for the class.

  • Save BookController.java with Ctrl+S.
  • Click the Rerun button in the Run window to restart the application.
  • Wait for Started BookcatalogApplication to appear in the console.

Before you test the POST endpoint, what do you think will happen when you send a JSON book to POST /api/books? Will you get back the same JSON, or something different?

  • Open IntelliJ's built-in terminal by pressing Alt+F12.
  • Create your first book by running this command:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Great Gatsby\",\"author\":\"F. Scott Fitzgerald\",\"publicationYear\":1925}"

Why curl.exe instead of curl?

On Windows, PowerShell has a built-in alias called curl that maps to a different command. Using curl.exe ensures you call the real curl program, which works correctly in both Command Prompt and PowerShell.

You should see a JSON response that includes all three fields you sent, plus a new id field. The database generated that id automatically.

  • Switch back to your browser tab showing http://localhost:8080/api/books and refresh the page.

The empty [] array is gone. You should now see a JSON array containing the book you just created, complete with its generated id.

Seeing a curl error or empty response?

  • If you see an error about curl not being recognized, make sure you typed curl.exe (with the .exe extension).
  • If you see a 415 error, double check the -H "Content-Type: application/json" header is included. Spring needs this to know the body is JSON.
  • If the browser still shows [] after refreshing, make sure the curl command returned a successful JSON response with an id field. If not, restart the app and try the command again.

Still stuck? Help me troubleshoot my curl POST request to the Spring Boot API.

Your first two API endpoints are live. You can list books and create new ones, and the data persists in the H2 database for as long as the application runs. Next up, you will add the remaining CRUD operations to get, update, and delete individual books.

Complete the CRUD Operations

In the previous step, you created a Book entity, a repository, and your first two endpoints. You can list all books and create new ones.

A real API needs more than just listing and creating. In this step, you will add the remaining CRUD operations: get a single book by its ID, update a book, and delete a book. But there is a problem lurking. What happens when someone requests a book that does not exist?

In this step, get ready to:
  • Add a GET by ID endpoint to retrieve a single book.
  • Add PUT and DELETE endpoints to update and remove books.
  • Discover what happens when you request a book that does not exist.
Add the GET by ID endpoint

Right now your API can list every book, but it cannot retrieve a single one. You need a GET endpoint that accepts a book's ID in the URL path and returns just that book.

  • In BookController.java, add this import at the top of the file with the other import statements:
import org.springframework.web.bind.annotation.PathVariable;
  • Now scroll down and add the getBookById method after the getAllBooks() method by pasting this code:
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
    return bookRepository.findById(id).get();
}

What does this code do?

@GetMapping("/{id}") maps GET requests to /api/books/1, /api/books/2, and so on. The {id} is a path variable that Spring automatically extracts and passes into the method parameter.

findById(id) returns an Optional<Book>. Calling .get() unwraps it to get the actual Book object. You will see later what happens if the book does not exist.

  • Save BookController.java.
  • Restart the application by clicking Rerun in the Run tool window (or press Ctrl+F5).
  • Wait for the "Started BookcatalogApplication" message in the console.

Where did my books go?

The H2 database runs entirely in memory. Every time you restart the application, the database is wiped clean. You will need to create a book again before testing the new endpoint.

  • Create a book first by running this command in your terminal:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Great Gatsby\",\"author\":\"F. Scott Fitzgerald\",\"publicationYear\":1925}"
  • Now test the GET by ID endpoint by running:
curl http://localhost:8080/api/books/1

You should see the single book returned as JSON, with its id, title, author, and publicationYear fields.

Getting a 404 or an error?

  • Make sure you restarted the application after saving your code changes. Java requires a full restart to pick up new code.
  • Make sure you ran the POST command first to create a book. The in-memory database starts empty after every restart.
  • Check that the @PathVariable import was added at the top of BookController.java.

Why isn't my GET by ID endpoint returning the book?

Add the PUT (update) and DELETE endpoints

Your API can now read books. Next, you need two more endpoints: one to update a book's details and one to delete a book entirely.

  • In BookController.java, add these two imports at the top of the file with the other import statements:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PutMapping;
  • Scroll down and add the updateBook method after the createBook() method by pasting this code:
@PutMapping("/{id}")
public Book updateBook(@PathVariable Long id, @RequestBody Book bookDetails) {
    // Find the existing book by its ID
    Book book = bookRepository.findById(id).get();
    // Overwrite each field with the new values from the request
    book.setTitle(bookDetails.getTitle());
    book.setAuthor(bookDetails.getAuthor());
    book.setPublicationYear(bookDetails.getPublicationYear());
    return bookRepository.save(book);
}

What does this code do?

@PutMapping("/{id}") maps HTTP PUT requests to this method. PUT is the standard HTTP method for replacing or updating a resource.

The method first retrieves the existing book from the database, then updates each of its fields with the new values from the request body. Finally, it saves the updated book back and returns it to the caller.

  • Now add the deleteBook method directly after the updateBook method you just added:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
    // Find the book to make sure it exists before deleting
    Book book = bookRepository.findById(id).get();
    bookRepository.delete(book);
    // Return HTTP 204 No Content (successful delete, nothing to send back)
    return ResponseEntity.noContent().build();
}

What does this code do?

@DeleteMapping("/{id}") maps HTTP DELETE requests. The method finds the book, removes it from the database, and returns HTTP 204 No Content. This status code means the deletion succeeded but there is no response body to send back.

  • Save BookController.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved BookController.java and move on to testing.

ⓧ I'd like to double check the full code

Your BookController.java should now contain these sections in order. Scroll up and confirm each is present:

  • The package declaration and all imports, including DeleteMapping, PathVariable, and PutMapping.
  • The class declaration with @RestController and @RequestMapping("/api/books") and the constructor that injects BookRepository.
  • The getAllBooks() method with @GetMapping (from the previous step).
  • The new getBookById() method with @GetMapping("/{id}").
  • The createBook() method with @PostMapping (from the previous step).
  • The new updateBook() method with @PutMapping("/{id}").
  • The new deleteBook() method with @DeleteMapping("/{id}").
  • Restart the application by clicking Rerun in the Run tool window (or press Ctrl+F5).
  • Wait for the "Started BookcatalogApplication" message.
  • Create a book again so you have data to work with by running:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Great Gatsby\",\"author\":\"F. Scott Fitzgerald\",\"publicationYear\":1925}"
  • Test the update endpoint by changing the publication year from 1925 to 1926:
curl -X PUT http://localhost:8080/api/books/1 -H "Content-Type: application/json" -d "{\"title\":\"The Great Gatsby\",\"author\":\"F. Scott Fitzgerald\",\"publicationYear\":1926}"

You should see the book returned with "publicationYear":1926. The update worked.

  • Test the delete endpoint by running:
curl -X DELETE http://localhost:8080/api/books/1
  • Confirm the book is gone by listing all books:
curl http://localhost:8080/api/books

You should see []. The book was deleted successfully.

Update or delete not working?

  • Make sure you added both the PutMapping and DeleteMapping imports at the top of the file.
  • If you are using PowerShell, use curl.exe instead of curl. PowerShell aliases curl to a different command.
  • Make sure you ran the POST command first. The in-memory database starts empty after every restart, so there is no book at ID 1 unless you create one.

Why isn't my PUT or DELETE endpoint working?

Hit the designed obstacle

All your CRUD operations work perfectly for books that exist. But what happens when an API consumer requests a book that is not in the database?

Before you run the next command, take a guess. What do you think the API will return when you request a book with ID 999?

  • Try requesting a book that does not exist by running:
curl http://localhost:8080/api/books/999

Instead of a helpful "not found" message, you get a 500 Internal Server Error with a wall of Java stack trace text. The key line is a NoSuchElementException. This is the raw exception from .get() being called on an empty Optional.

This error is intentional

This is exactly the problem you are going to fix. A 500 error with a stack trace is confusing for anyone consuming your API. It also leaks internal details about your code, which is a security risk.

API consumers expect a clean 404 Not Found response with a helpful message like "Book not found with id: 999". You will build exactly that in the next step.

Your CRUD operations are complete, but that ugly 500 error is a real problem. In the next step, you will add proper error handling with @ControllerAdvice to turn that stack trace into a clean, professional JSON error response.

Add Proper Error Handling

Your CRUD endpoints are working, but you uncovered a real problem at the end of the last step. Requesting a book that does not exist returns a confusing 500 Internal Server Error with a raw Java stack trace.

Exposing raw exceptions to API consumers is unprofessional and a security risk. In this step, you will build a proper error handling layer using @ControllerAdvice, a key Spring Boot pattern that interviewers specifically look for.

In this step, get ready to:
  • Create a custom exception class that produces a clear "not found" message.
  • Build a global exception handler that catches errors and returns clean JSON responses.
  • Update the controller so that missing books trigger a proper 404 instead of a 500.
Create the BookNotFoundException class

Right now, calling .get() on an empty result throws a generic NoSuchElementException. A custom exception class lets you attach a meaningful error message that your API can relay to the caller.

  • In IntelliJ, right-click the com.example.bookcatalog package in the Project panel on the left.
  • Select New, then Java Class.
  • Type BookNotFoundException and press Enter.
  • Replace the entire contents of the file with this code:
package com.example.bookcatalog;

// Custom exception for when a book ID doesn't exist
public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        // Pass a descriptive message to the parent RuntimeException
        super("Book not found with id: " + id);
    }
}

What does this code do?

This class extends RuntimeException, which means Spring does not force you to declare it in every method signature. It is an unchecked exception.

The constructor takes the missing book's id and builds a human-readable message like Book not found with id: 42. Your exception handler will forward this message straight into the JSON response.

  • Save BookNotFoundException.java with Ctrl+S.
  • Confirm that IntelliJ shows no red underlines in the file. The class should compile cleanly.

Seeing a red underline?

  • Make sure the package line at the top reads package com.example.bookcatalog; exactly.
  • Verify the class name matches the filename. IntelliJ expects the class BookNotFoundException in a file called BookNotFoundException.java.

Still stuck? Ask for help.

Create the GlobalExceptionHandler with @ControllerAdvice

A custom exception on its own does not change what the API returns. You need a handler that intercepts the exception before Spring's default error page kicks in.

  • Right-click the com.example.bookcatalog package again and select New, then Java Class.
  • Type GlobalExceptionHandler and press Enter.
  • Replace the entire contents of the file with this code:
package com.example.bookcatalog;

import java.time.LocalDateTime;
import java.util.Map;

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

// Catches exceptions thrown across all controllers
@ControllerAdvice
public class GlobalExceptionHandler {

    // Runs whenever a BookNotFoundException is thrown
    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleBookNotFound(BookNotFoundException ex) {
        // Build a structured JSON error response
        Map<String, Object> body = Map.of(
                "timestamp", LocalDateTime.now().toString(),
                "status", 404,
                "error", "Not Found",
                "message", ex.getMessage()
        );
        return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
    }
}

What does this code do?

  • @ControllerAdvice tells Spring to apply this class across every controller in the application. You do not need to register it anywhere.
  • @ExceptionHandler(BookNotFoundException.class) marks the method that runs whenever that specific exception is thrown during a request.
  • The method builds a Map with four fields (timestamp, status, error, message) and wraps it in a ResponseEntity with HTTP status 404. Spring automatically serializes the map to JSON.

Why @ControllerAdvice instead of try-catch?

You could wrap every controller method in a try-catch block, but that scatters error handling across every endpoint. @ControllerAdvice centralizes it in one place. If you add ten more controllers later, they all get the same error handling for free.

  • Save GlobalExceptionHandler.java with Ctrl+S.
  • Confirm that IntelliJ shows no red underlines. Both new classes should compile without errors.

Seeing import errors?

  • Make sure all seven import statements are present at the top of the file. A missing import causes IntelliJ to show the class name in red.
  • If IntelliJ suggests auto-importing, press Alt+Enter on the red text and select the correct class from the list.

Need more help? Ask here.

Update the controller to throw BookNotFoundException

The exception class and handler are ready, but your controller still uses .get(). You need to swap every .findById(id).get() call to .findById(id).orElseThrow() so it throws your custom exception instead of the generic one.

  • Open BookController.java and find the getBookById method.
  • Find this line:
return bookRepository.findById(id).get();
  • Replace it so the line now looks like this:
return bookRepository.findById(id)
        .orElseThrow(() -> new BookNotFoundException(id));
  • Now find the updateBook method. Find this line:
Book book = bookRepository.findById(id).get();
  • Replace it so the line now looks like this:
Book book = bookRepository.findById(id)
        .orElseThrow(() -> new BookNotFoundException(id));
  • Finally, find the same Book book = bookRepository.findById(id).get(); line inside the deleteBook method.
  • Replace it with the same pattern:
Book book = bookRepository.findById(id)
        .orElseThrow(() -> new BookNotFoundException(id));

What changed?

.get() throws a generic NoSuchElementException with no useful message. .orElseThrow() lets you throw your own BookNotFoundException instead, which the GlobalExceptionHandler catches and converts into a clean 404 JSON response.

  • Save BookController.java with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: BookNotFoundException.java, GlobalExceptionHandler.java, and BookController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these pieces is in place:

  • BookNotFoundException.java extends RuntimeException with a constructor that takes Long id.
  • GlobalExceptionHandler.java is annotated with @ControllerAdvice and has a handleBookNotFound method returning a 404 response.
  • BookController.java has all three .get() calls replaced with .orElseThrow(() -> new BookNotFoundException(id)) in getBookById, updateBook, and deleteBook.
  • Stop the running application by clicking the red square Stop button in the top toolbar.
  • Restart the application by clicking the green play button next to the main method in BookcatalogApplication.java, or press Shift+F10.

Before you run the next command, think about what you expect to get back when you request a book with an ID that does not exist. Will it be the same 500 error as before, or something different?

  • Test the error handling by requesting a non-existent book. Run this in your terminal:
curl http://localhost:8080/api/books/999

You should see a clean JSON response with a 404 status and a helpful message instead of a stack trace.

What should I see?

The response looks something like this:

{
  "status": 404,
  "error": "Not Found",
  "message": "Book not found with id: 999",
  "timestamp": "2026-08-17T..."
}

Compare that to the wall of stack trace you saw in the previous step. Your API consumers now get a clear, actionable error message with the right HTTP status code.

Still seeing a 500 error?

  • Make sure you restarted the application after saving all three files. Changes do not take effect until you restart.
  • Check that all three .get() calls in BookController.java have been replaced with .orElseThrow(() -> new BookNotFoundException(id)). A single missed .get() still triggers the old error path.
  • Verify that GlobalExceptionHandler.java has the @ControllerAdvice annotation on the class. Without it, Spring does not pick up the handler.

Ask for debugging help.

Your API now handles errors like a professional service. Instead of leaking Java internals to callers, it returns structured JSON with the right status code and a clear message. Next up, you can take on the Secret Mission to add input validation so the API also rejects bad data before it reaches the database.

Secret mission

Add Input Validation

Your API currently accepts any data you throw at it. A book with a blank title? Sure. A publication year of zero? No problem. Try adding Jakarta Validation so the API rejects bad input with clear, field-level error messages.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with an in-memory database, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, pause the running application, or delete everything entirely.

Resources you used:

  • The running Spring Boot application on localhost:8080.
  • The bookcatalog project folder on your machine.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with new endpoints.

  • Your Spring Boot application stays running in IntelliJ on localhost:8080.
  • Your project files stay in the bookcatalog folder.
  • The H2 in-memory database resets every time you restart the application, so no data accumulates on disk.

Pause - I'll come back to this later

Stop the running application to free up the port and memory, but keep your project files so you can pick up where you left off.

  • In IntelliJ, click the red square stop button in the Run panel at the bottom of the window to stop the Spring Boot application.
  • Your project files stay in the bookcatalog folder, ready to reopen in IntelliJ whenever you want to continue.
  • Any books you created during this session are gone once the application stops. The H2 in-memory database does not persist data between restarts.

Delete - I don't want to use this again

Remove all project files and stop the application entirely.

  • In IntelliJ, click the red square stop button in the Run panel at the bottom of the window to stop the Spring Boot application.
  • Close the project in IntelliJ by clicking **File**, then **Close Project**.
  • Delete the bookcatalog folder from wherever you extracted it. Open File Explorer (Windows) or Finder (macOS), navigate to the folder containing bookcatalog, right-click it, and select Delete.
  • Alternatively, delete it from a terminal. On Windows, open Command Prompt, navigate to the parent folder, and run:
rmdir /s /q bookcatalog

On macOS or Linux, run:

rm -rf bookcatalog

Nice Work!

Nice Work!

Nice work! You just built a production-style REST API from scratch using Spring Boot 4.1. You can now create, read, update, and delete books through HTTP requests and get back clean JSON responses with proper status codes.

You've learned how to:

  • Built a full CRUD REST API using the Controller-Repository-Entity pattern, with proper HTTP methods (GET, POST, PUT, DELETE) and status codes.
  • Created a JPA entity that maps to a database table with zero SQL, and used Spring Data JPA to auto-generate a full data access layer from a single interface (BookRepository).
  • Implemented centralized error handling with @ControllerAdvice that returns clean JSON errors instead of stack traces.
  • Secret Mission: Added Jakarta Validation to reject invalid input with field-level error messages.

Ready to quiz yourself?