Build a Bookstore REST API

Build a Spring Boot REST API with CRUD, validation, and pagination.

Introduction

30 Second Summary

Every time you search for a book online, add one to your cart, or check if it's in stock, a REST API is handling that request behind the scenes. These APIs are the backbone of modern web applications, and building them well is a core skill for Java backend developers.

In this project, you will build a fully functional Bookstore REST API using Spring Boot. You'll create endpoints to add, retrieve, update, and delete books, complete with input validation, error handling, and paginated results.

What You'll Build

A production-patterned REST API with Spring Boot that manages a catalog of books, backed by an H2 in-memory database with a browser-accessible console.

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

  • A Spring Boot application with layered architecture separating your Controller, Service, and Repository layers.
  • Full CRUD REST endpoints for creating, reading, updating, and deleting books with proper HTTP status codes.
  • Input validation and global exception handling that returns meaningful JSON error messages.
  • Secret Mission: Add a search endpoint that filters books by author, title keyword, or publication year range.

Are there any prerequisites?

You should be comfortable with Java OOP concepts like classes, interfaces, and annotations. You'll also need VS Code installed on your machine.

Set Up Your Development Environment

Every Spring Boot application needs a Java runtime to compile and execute your code. Before you can build the bookstore API, you need a working Java Development Kit and the right VS Code extensions to support Java development.

In this step, you will install JDK 21, add two extension packs to VS Code, and generate a fresh Spring Boot project using Spring Initializr. By the end, your application will be running locally on port 8080.

In this step, get ready to:
  • Install JDK 21 and VS Code extensions for Java development.
  • Generate a Spring Boot project with Spring Initializr.
  • Run the application and verify it starts on port 8080.
Install JDK 21 and VS Code extensions

The JDK (Java Development Kit) includes the compiler and runtime your Spring Boot app needs. VS Code's Java extensions add IntelliSense, debugging, and project management features that make writing Java code much smoother.

  • Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Check whether JDK 21 is already installed by running this command:
java -version

✔️ I see version 21 or higher

Your output shows something like openjdk version "21.0.x" or java version "21.0.x". JDK 21 is ready to go.

ⓧ I see an older version

If you see a version lower than 21 (for example 17.0.x or 11.0.x), you need to install JDK 21. Your older version may remain on your system. The new install will take priority once you complete the steps below.

macOS

  • Install JDK 21 using Homebrew by running:
brew install openjdk@21
  • Create a symlink so macOS can find the JDK by running:
sudo ln -sfn $(brew --prefix)/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
  • Close and reopen your terminal, then run java -version again to confirm it now shows version 21.

Windows

  • Install JDK 21 using winget by running:
winget install -e --id EclipseAdoptium.Temurin.21.JDK
  • Close and reopen your terminal, then run java -version again to confirm it now shows version 21.

ⓧ Command not found

Java is not installed on your system yet. Follow the instructions below to install JDK 21.

macOS

  • Install Homebrew first (if you do not have it) by visiting https://brew.sh and following the one-liner on the page.
  • Install JDK 21 using Homebrew by running:
brew install openjdk@21
  • Create a symlink so macOS can find the JDK by running:
sudo ln -sfn $(brew --prefix)/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
  • Close and reopen your terminal, then run java -version to confirm it shows version 21.

Windows

  • Install JDK 21 using winget by running:
winget install -e --id EclipseAdoptium.Temurin.21.JDK
  • Close and reopen your terminal, then run java -version to confirm it shows version 21.

Still not seeing version 21?

On macOS, the system may still point to an older Java. Make sure you ran the symlink command above and restarted your terminal.

On Windows, the Temurin installer sets JAVA_HOME automatically. If java -version still shows the old version, close all terminal windows and open a fresh one.

Help me troubleshoot my JDK 21 installation

Now install the two VS Code extension packs that provide Java language support and Spring Boot tooling.

  • Open the Extensions view by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Extension Pack for Java in the search box.
  • Click Install on the result published by Microsoft.
  • Clear the search box, then type Spring Boot Extension Pack.
  • Click Install on the result published by VMware.

What do these extensions do?

The Extension Pack for Java adds language support, debugging, testing, and Maven integration. The Spring Boot Extension Pack adds Spring-specific features like the Boot Dashboard and Spring Initializr integration.

Extensions not appearing in search?

Make sure you are connected to the internet. If search returns no results, try reloading VS Code with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), then type Reload Window and press Enter.

Help me install VS Code Java extensions

Generate the Spring Boot project

Spring Initializr is a web tool that generates a pre-configured Spring Boot project with all the dependencies you choose. You will select the build tool, Java version, and libraries your bookstore API needs.

  • Open your browser and go to https://start.spring.io.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot version to 3.5.15.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to bookstore.
  • Set Java version to 21.
  • Click ADD DEPENDENCIES and search for each of these, clicking to add them one at a time:
  • Click GENERATE to download the bookstore.zip file.

Why these four dependencies?

Spring Web lets you build REST endpoints. Spring Data JPA provides the database access layer with built-in CRUD methods.

H2 Database gives you an in-memory database that requires zero setup. Validation adds annotations like @NotBlank to enforce input rules on your API.

  • Extract the downloaded bookstore.zip file to your Desktop.

Quick extraction

On macOS, double-click the ZIP file in Finder. On Windows, right-click the ZIP and select Extract All.

Open the project and run the application

With the project extracted, you need to open it in VS Code, let Maven download the dependencies, and start the application to confirm everything works.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the bookstore folder you just extracted, then click Open.

VS Code detects the Maven project and begins importing dependencies automatically. You may see a progress notification in the bottom-right corner.

What is Maven doing?

Maven reads the pom.xml file in your project and downloads all the libraries (Spring Web, JPA, H2, Validation) from the internet. This can take a minute or two the first time.

  • Wait for the import to finish. The progress notification disappears when it completes.
  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the Spring Boot application by running:
./mvnw spring-boot:run

What does this command do?

The ./mvnw file is the Maven Wrapper that ships with your project. It downloads the correct Maven version automatically, so you never need to install Maven globally.

The spring-boot:run goal compiles your code and starts the embedded web server.

After about 10-20 seconds, you should see output in the terminal that includes a line like Started BookstoreApplication and a message showing Tomcat started on port 8080.

  • Open your browser and go to http://localhost:8080.

You should see a default error page (a "Whitelabel Error Page"). This is expected because you have not created any endpoints yet.

Seeing "Permission denied" on ./mvnw?

On macOS or Linux, the Maven Wrapper file may not have execute permissions. Fix this by running:

chmod +x ./mvnw

Then run ./mvnw spring-boot:run again. On Windows, use mvnw.cmd spring-boot:run instead (without the ./ prefix).

Help me start my Spring Boot application

✔️ Awesome, I've got everything!

Great. Make sure you keep the terminal running so the server stays active.

ⓧ I'd like to double check the full code

Here is the main application file that Spring Initializr generated for you. Check that yours matches:

package com.example.bookstore;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BookstoreApplication {

    public static void main(String[] args) {
        SpringApplication.run(BookstoreApplication.class, args);
    }
}

Your development environment is fully set up. Next up, you will create the Book entity and repository to start storing data in your H2 database.

Create the Book Entity and Repository

Your development environment is ready and the bare Spring Boot application starts successfully. Now it's time to give the API something to work with: a Book data model and a database layer.

Every REST API needs a way to represent and persist data. In this step, you'll define what a book looks like using a JPA entity, wire up a repository for database operations, and seed some sample data into an H2 in-memory database you can inspect in your browser.

In this step, get ready to:
  • Create a Book entity class that maps to a database table.
  • Create a repository interface for database operations and configure H2.
  • Seed sample books and verify them in the H2 browser console.
Create the Book JPA entity

A JPA entity is a Java class that maps directly to a database table. Each instance of the class represents one row in that table.

  • In the VS Code Explorer sidebar, right-click the src/main/java/com/example/bookstore folder.
  • Select New Folder and name it model.
  • Right-click the new model folder, select New File, and name it Book.java.
  • Paste the following code into Book.java:
package com.example.bookstore.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    private String title;

    @NotBlank(message = "Author is required")
    private String author;

    @Size(min = 10, max = 13, message = "ISBN must be between 10 and 13 characters")
    private String isbn;

    @NotNull(message = "Publication year is required")
    private Integer publicationYear;

    @NotNull(message = "Price is required")
    private Double price;

    // No-argument constructor required by JPA
    public Book() {
    }

    // Full constructor for creating Book instances in code
    public Book(String title, String author, String isbn, Integer publicationYear, Double price) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
        this.publicationYear = publicationYear;
        this.price = price;
    }

    // Getters and setters for each field
    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; }
    public Integer getPublicationYear() { return publicationYear; }
    public void setPublicationYear(Integer publicationYear) { this.publicationYear = publicationYear; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
}

What does this code do?

The @Entity annotation tells JPA to create a database table from this class. @Id and @GeneratedValue mark the id field as the auto-incrementing primary key.

The validation annotations (@NotBlank, @NotNull, @Size) define rules that Spring enforces when data comes in through the API. For example, a book must always have a title and author, and the ISBN must be between 10 and 13 characters.

JPA requires a no-argument constructor to create entity instances. The full constructor is a convenience for creating books in code.

  • Save Book.java.
  • You won't see a visible change yet. The next chunk connects this entity to the database.

Seeing red underlines in VS Code?

If jakarta.persistence or jakarta.validation imports show errors, the Maven dependencies may not have finished downloading. Wait for the progress indicator in the bottom status bar to complete, or open the Command Palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and run Java: Clean Java Language Server Workspace.

Help me fix my Jakarta import errors in VS Code.

✔️ Awesome, I've got everything!

Great. Double check you've saved Book.java before moving on.

ⓧ I'd like to double check the full code

package com.example.bookstore.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    private String title;

    @NotBlank(message = "Author is required")
    private String author;

    @Size(min = 10, max = 13, message = "ISBN must be between 10 and 13 characters")
    private String isbn;

    @NotNull(message = "Publication year is required")
    private Integer publicationYear;

    @NotNull(message = "Price is required")
    private Double price;

    public Book() {
    }

    public Book(String title, String author, String isbn, Integer publicationYear, Double price) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
        this.publicationYear = publicationYear;
        this.price = price;
    }

    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; }
    public Integer getPublicationYear() { return publicationYear; }
    public void setPublicationYear(Integer publicationYear) { this.publicationYear = publicationYear; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
}
Create the BookRepository and configure H2

Spring Data JPA eliminates boilerplate database code. You define an interface, and Spring auto-generates the implementation with full CRUD operations at runtime.

  • In the Explorer sidebar, right-click the src/main/java/com/example/bookstore folder.
  • Select New Folder and name it repository.
  • Right-click the repository folder, select New File, and name it BookRepository.java.
  • Paste the following code into BookRepository.java:
package com.example.bookstore.repository;

import com.example.bookstore.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA generates CRUD methods automatically from this interface
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() without writing any implementation code.

The two type parameters tell Spring which entity this repository manages (Book) and what type the primary key is (Long).

  • Save BookRepository.java.
  • You won't see a change yet. The next chunk configures the database connection.

✔️ Awesome, I've got everything!

Great. Double check you've saved BookRepository.java before moving on.

ⓧ I'd like to double check the full code

package com.example.bookstore.repository;

import com.example.bookstore.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}

Now configure the database connection and H2 console so you can inspect your data in a browser.

  • Open src/main/resources/application.properties in the Explorer sidebar.
  • Replace its contents with the following configuration:
spring.application.name=bookstore

# Enable the H2 browser console for inspecting data
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Configure the in-memory database
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=

# Show SQL queries in the terminal for debugging
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop

# Ensure data.sql runs after Hibernate creates tables
spring.sql.init.mode=always
spring.jpa.defer-datasource-initialization=true

What do these properties do?

The spring.h2.console.enabled=true setting exposes a browser UI at /h2-console where you can run SQL queries against your database.

The spring.jpa.defer-datasource-initialization=true property is critical. Without it, Spring tries to run your seed data SQL before Hibernate creates the tables, causing errors.

The ddl-auto=create-drop setting means Hibernate creates the schema on startup and drops it on shutdown. This is perfect for development with an in-memory database.

  • Save application.properties.
  • You won't see a change yet. The next chunk adds seed data to verify the full setup.

Typo in the datasource URL?

The JDBC URL must be exactly jdbc:h2:mem:testdb. A typo here means the H2 console connects to a different (empty) database instance, and you won't see your data.

Help me fix my H2 console datasource URL.

✔️ Awesome, I've got everything!

Great. Double check you've saved application.properties before moving on.

ⓧ I'd like to double check the full code

spring.application.name=bookstore

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop

spring.sql.init.mode=always
spring.jpa.defer-datasource-initialization=true
Seed sample books and verify in the H2 console

An empty database is hard to test against. You'll add a SQL script that automatically inserts sample books every time the application starts.

  • Right-click the src/main/resources folder in the Explorer sidebar.
  • Select New File and name it data.sql.
  • Paste the following SQL into data.sql:
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('The Pragmatic Programmer', 'David Thomas', '9780135957059', 2019, 49.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Clean Code', 'Robert C. Martin', '9780132350884', 2008, 39.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Spring in Action', 'Craig Walls', '9781617297571', 2022, 44.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Effective Java', 'Joshua Bloch', '9780134685991', 2018, 42.99);

What does this code do?

Spring Boot automatically runs any data.sql file it finds in src/main/resources on startup. These INSERT statements seed four books into your book table.

Notice the column names use snake_case (publication_year) while the Java field uses camelCase (publicationYear). Hibernate handles this naming conversion automatically.

  • Save data.sql.
  • If your application is still running from Step 1, stop it by pressing Ctrl+C in the terminal.
  • Start the application again by running:
./mvnw spring-boot:run
  • In the terminal output, look for Hibernate: create table book and several Hibernate: insert into book lines. These confirm the table was created and data was seeded.
  • Open your browser and navigate to http://localhost:8080/h2-console.
  • In the JDBC URL field, make sure it says jdbc:h2:mem:testdb.
  • Leave User Name as sa and the Password field empty.
  • Click Connect.
  • In the SQL input box, type SELECT * FROM BOOK and click Run.
  • You should see all four books displayed in a table.

H2 console shows no tables?

Make sure the JDBC URL field says jdbc:h2:mem:testdb (not the default jdbc:h2:~/test). If the URL doesn't match your application.properties, the console connects to a separate empty database.

If you see an error about the BOOK table not existing in the terminal, check that spring.jpa.defer-datasource-initialization=true is set in your properties file. Without it, data.sql runs before Hibernate creates the table.

Help me debug my missing BOOK table in the H2 console.

✔️ Awesome, I've got everything!

Great. Double check you've saved data.sql and that all four books appear in the H2 console query.

ⓧ I'd like to double check the full code

INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('The Pragmatic Programmer', 'David Thomas', '9780135957059', 2019, 49.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Clean Code', 'Robert C. Martin', '9780132350884', 2008, 39.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Spring in Action', 'Craig Walls', '9781617297571', 2022, 44.99);
INSERT INTO book (title, author, isbn, publication_year, price) VALUES ('Effective Java', 'Joshua Bloch', '9780134685991', 2018, 42.99);

Your data model is wired up and the database is seeded. Next up, you'll build the service layer and REST controller that expose these books as API endpoints.

Build the Service Layer and REST Controller

Your Book entity and repository are ready to store and retrieve data. Now it's time to build the layer that clients actually interact with: the REST API.

Spring Boot's layered architecture separates concerns into distinct classes. The Service layer holds your business logic, and the Controller layer maps HTTP requests to service methods.

In this step, get ready to:
  • Create a BookService class with CRUD business logic.
  • Create a BookController with REST endpoints for managing books.
  • Test all five endpoints using curl.
Create the service layer and exception class

The service layer sits between the controller and the repository. It contains business logic and decides what to do when things go wrong (like requesting a book that doesn't exist).

Why a separate service layer?

You could call the repository directly from the controller, but that mixes HTTP concerns with business logic. A dedicated service class keeps each layer focused on one job.

Controllers handle HTTP (request parsing, status codes). Services handle rules (what happens when a book isn't found). Repositories handle data access. This separation makes each layer independently testable.

Before writing the service, you need a custom exception it can throw when a book isn't found.

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

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

  • BookNotFoundException extends RuntimeException, making it an unchecked exception that Spring can intercept.
  • The constructor accepts a message string so callers can include context like the missing ID.
  • Save BookNotFoundException.java.

You won't see a visible change yet. The service you create next uses this exception.

✔️ Awesome, I've got everything!

Great. Save the file and move on to the service class.

ⓧ I'd like to double check the full code

package com.example.bookstore.exception;

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(String message) {
        super(message);
    }
}

Now create the service class that contains all your business logic.

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

import com.example.bookstore.exception.BookNotFoundException;
import com.example.bookstore.model.Book;
import com.example.bookstore.repository.BookRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
public class BookService {

    // Repository dependency injected via constructor
    private final BookRepository bookRepository;

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

    // Return a paginated list of all books
    public Page<Book> getAllBooks(Pageable pageable) {
        return bookRepository.findAll(pageable);
    }

    // Find a single book or throw if it doesn't exist
    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
    }

    // Persist a new book to the database
    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    // Update all fields on an existing book
    public Book updateBook(Long id, Book bookDetails) {
        Book book = getBookById(id);
        book.setTitle(bookDetails.getTitle());
        book.setAuthor(bookDetails.getAuthor());
        book.setIsbn(bookDetails.getIsbn());
        book.setPublicationYear(bookDetails.getPublicationYear());
        book.setPrice(bookDetails.getPrice());
        return bookRepository.save(book);
    }

    // Delete a book, throwing if the ID doesn't exist
    public void deleteBook(Long id) {
        Book book = getBookById(id);
        bookRepository.delete(book);
    }
}

What does this code do?

  • @Service tells Spring to register this class as a managed bean so it can be injected into other classes.
  • Constructor injection means Spring passes the BookRepository in automatically. No @Autowired annotation needed when there's a single constructor.
  • getAllBooks accepts a Pageable parameter so clients can request specific pages and sort orders.
  • getBookById uses orElseThrow to throw your custom exception when the ID doesn't match any record.
  • updateBook first fetches the existing book (re-using getBookById for the not-found check), then overwrites every field before saving.
  • Save BookService.java.

You won't see a change yet. The controller in the next substep exposes these methods as HTTP endpoints.

Seeing red underlines in VS Code?

  • If BookNotFoundException shows as unresolved, confirm you saved the exception file first and that the package declaration matches com.example.bookstore.exception.
  • If Pageable or Page can't be resolved, wait for VS Code's Java Language Server to finish indexing. The status bar shows a spinning icon while it loads.
  • Help me fix unresolved imports in my BookService file.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files and move on to the controller.

ⓧ I'd like to double check the full code

package com.example.bookstore.service;

import com.example.bookstore.exception.BookNotFoundException;
import com.example.bookstore.model.Book;
import com.example.bookstore.repository.BookRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
public class BookService {

    private final BookRepository bookRepository;

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

    public Page<Book> getAllBooks(Pageable pageable) {
        return bookRepository.findAll(pageable);
    }

    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException("Book not found with id: " + id));
    }

    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    public Book updateBook(Long id, Book bookDetails) {
        Book book = getBookById(id);
        book.setTitle(bookDetails.getTitle());
        book.setAuthor(bookDetails.getAuthor());
        book.setIsbn(bookDetails.getIsbn());
        book.setPublicationYear(bookDetails.getPublicationYear());
        book.setPrice(bookDetails.getPrice());
        return bookRepository.save(book);
    }

    public void deleteBook(Long id) {
        Book book = getBookById(id);
        bookRepository.delete(book);
    }
}
Create the REST controller

The controller is the entry point for all HTTP requests. It maps URLs and HTTP methods to your service layer methods, and decides what status code to return.

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

import com.example.bookstore.model.Book;
import com.example.bookstore.service.BookService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;

import java.net.URI;

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

    // Service dependency injected via constructor
    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // GET /api/books - returns a paginated list of books
    @GetMapping
    public Page<Book> getAllBooks(Pageable pageable) {
        return bookService.getAllBooks(pageable);
    }

    // GET /api/books/{id} - returns a single book by ID
    @GetMapping("/{id}")
    public Book getBookById(@PathVariable Long id) {
        return bookService.getBookById(id);
    }

    // POST /api/books - creates a new book, returns 201 with Location header
    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        Book created = bookService.createBook(book);
        URI location = URI.create("/api/books/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }

    // PUT /api/books/{id} - updates an existing book, returns 200
    @PutMapping("/{id}")
    public Book updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
        return bookService.updateBook(id, book);
    }

    // DELETE /api/books/{id} - deletes a book, returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

  • @RestController combines @Controller and @ResponseBody. Every method returns JSON automatically.
  • @RequestMapping("/api/books") sets the base path for all endpoints in this controller.
  • ResponseEntity.created(location) returns HTTP 201 and adds a Location header pointing to the new resource.
  • ResponseEntity.noContent().build() returns HTTP 204 with no body, the standard response for a successful delete.
  • @Valid on the request body triggers Jakarta Bean Validation. You'll add proper error handling for this in the next step.
  • Save BookController.java.

Seeing compilation errors?

  • Check that your imports match exactly. VS Code sometimes auto-imports the wrong Page class. You need org.springframework.data.domain.Page.
  • Verify the BookService import resolves. The package should be com.example.bookstore.service.BookService.
  • Help me troubleshoot compilation errors in my BookController.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file. Time to start the app and test your endpoints.

ⓧ I'd like to double check the full code

package com.example.bookstore.controller;

import com.example.bookstore.model.Book;
import com.example.bookstore.service.BookService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;

import java.net.URI;

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

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping
    public Page<Book> getAllBooks(Pageable pageable) {
        return bookService.getAllBooks(pageable);
    }

    @GetMapping("/{id}")
    public Book getBookById(@PathVariable Long id) {
        return bookService.getBookById(id);
    }

    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        Book created = bookService.createBook(book);
        URI location = URI.create("/api/books/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public Book updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
        return bookService.updateBook(id, book);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}
Test the API endpoints

All three layers are wired together. Time to start the application and verify every endpoint returns the correct status code and data.

  • If the application is still running from the previous step, stop it by pressing Ctrl+C in your terminal.
  • Restart the application by running:
./mvnw spring-boot:run

You should see Spring Boot start up with no errors and the line Started BookstoreApplication in the output.

  • Open a second terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • Test the POST endpoint by creating a new book. Run this command:
curl -i -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Design Patterns","author":"Gang of Four","isbn":"9780201633610","publicationYear":1994,"price":44.99}'

You should see a 201 Created status with a Location header pointing to /api/books/5. The response body shows the new book with an auto-generated id.

  • Test the GET endpoint to list all books with pagination by running:
curl http://localhost:8080/api/books

You should see a JSON response with a content array containing all 5 books (4 seeded plus the one you just created), along with pagination metadata like totalElements and totalPages.

  • Test the GET-by-ID endpoint by running:
curl http://localhost:8080/api/books/1

You should see the full JSON for "The Pragmatic Programmer" with all its fields.

  • Test the PUT endpoint to update the book's price by running:
curl -X PUT http://localhost:8080/api/books/5 -H "Content-Type: application/json" -d '{"title":"Design Patterns","author":"Gang of Four","isbn":"9780201633610","publicationYear":1994,"price":49.99}'

You should see the updated book JSON with the price changed to 49.99.

  • Test the DELETE endpoint by running:
curl -i -X DELETE http://localhost:8080/api/books/5

You should see a 204 No Content status with an empty response body. This confirms the book was deleted.

Why different status codes?

  • 201 Created tells the client a new resource was created and where to find it.
  • 200 OK is the default for successful reads and updates.
  • 204 No Content means success but nothing to return. Perfect for deletes.

Using the correct HTTP status codes is what makes an API RESTful. Clients rely on these codes to decide what happened without parsing the body.

Getting connection refused?

  • Make sure the application is running in your first terminal. Look for the Started BookstoreApplication log line.
  • If you see a 500 error when creating a book, check that your JSON field names match the entity exactly (publicationYear not publication_year).
  • On Windows, curl uses double quotes differently. Replace the outer single quotes with double quotes and escape the inner quotes with backslashes.
  • Help me debug my curl commands against the Spring Boot API.

Your API is fully functional with all five CRUD endpoints responding correctly. Next up, you'll add input validation and custom error handling so clients get meaningful feedback when they send bad data.

Add Validation and Global Exception Handling

Your API endpoints are working, but they blindly trust any data a client sends. If someone submits a book with no title or requests an ID that doesn't exist, your API needs to respond with clear, structured error messages.

In this step, you'll build a global exception handler that intercepts validation failures and missing-resource errors. Instead of Spring's default verbose error format, your API will return clean JSON that any frontend or API consumer can parse.

In this step, get ready to:
  • Review the validation annotations already on your Book entity.
  • Create a GlobalExceptionHandler for custom error responses.
  • Test validation errors and not-found exceptions with curl.
Review your validation annotations

Your Book entity already has Jakarta Bean Validation annotations from when you created it in Step 2. These annotations define the rules for what counts as valid input.

  • In VS Code, open Book.java from the Explorer sidebar.
  • Confirm you see these annotations on the fields:
@NotBlank(message = "Title is required")
private String title;

@NotBlank(message = "Author is required")
private String author;

@Size(min = 10, max = 13, message = "ISBN must be between 10 and 13 characters")
private String isbn;

@NotNull(message = "Publication year is required")
private Integer publicationYear;

@NotNull(message = "Price is required")
private Double price;

What do these annotations do?

Each annotation defines a constraint that Spring checks before your controller method runs.

  • @NotBlank rejects null values and empty strings (including whitespace-only strings).
  • @NotNull rejects null values but allows empty strings or zero.
  • @Size(min=10, max=13) checks that the string length falls within the specified range.

The message attribute on each annotation becomes the error text returned to the client.

Your BookController already uses @Valid on the @RequestBody parameters for createBook and updateBook. This tells Spring to run validation before executing the method.

What happens without a custom handler?

When validation fails, Spring throws a MethodArgumentNotValidException. Without a custom handler, Spring returns its default error format with nested arrays and internal details that are hard for API consumers to parse.

A custom exception handler intercepts these exceptions and returns a simpler, more useful JSON structure.

Create the global exception handler

The @RestControllerAdvice annotation marks a class as a global exception handler that applies to all controllers. Each @ExceptionHandler method catches a specific exception type and returns a custom response.

  • In the Explorer sidebar, right-click the exception package folder (src/main/java/com/example/bookstore/exception).
  • Select New File and name it GlobalExceptionHandler.java.
  • Paste the following code into the new file:
package com.example.bookstore.exception;

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

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

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle requests for books that don't exist
    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleBookNotFound(BookNotFoundException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    // Handle validation failures from @Valid annotations
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 400);
        body.put("error", "Bad Request");

        // Collect each field's error message into a map
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                fieldErrors.put(error.getField(), error.getDefaultMessage())
        );
        body.put("fieldErrors", fieldErrors);

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }
}

What does this code do?

  • @RestControllerAdvice makes this class a centralized error handler for every controller in the application.
  • handleBookNotFound catches BookNotFoundException and returns a 404 JSON body with a timestamp, status code, and the exception message.
  • handleValidationErrors catches MethodArgumentNotValidException and builds a fieldErrors map pairing each invalid field name with its validation message.
  • Save GlobalExceptionHandler.java.

Seeing red squiggles in the file?

Make sure the package statement at the top matches com.example.bookstore.exception. Also confirm the file is saved inside the exception folder, not at the project root.

Help me fix import errors in my GlobalExceptionHandler

✔️ 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.bookstore.exception;

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

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

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleBookNotFound(BookNotFoundException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 400);
        body.put("error", "Bad Request");

        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                fieldErrors.put(error.getField(), error.getDefaultMessage())
        );
        body.put("fieldErrors", fieldErrors);

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }
}
Test validation and error handling

With the handler in place, your API now returns structured JSON for both validation errors and missing resources. Restart the application so Spring picks up the new class.

  • Stop the running application by pressing Ctrl+C in the terminal.
  • Restart it by running:
./mvnw spring-boot:run

Once the app starts, test a validation failure by sending a book with a blank title.

  • Open a second terminal and run this curl command:
curl -s -X POST http://localhost:8080/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "", "author": "", "publicationYear": null, "price": null}' | python3 -m json.tool

What should I see?

You should see a 400 response with a fieldErrors map listing each invalid field and its message. For example, title shows "Title is required" and price shows "Price is required".

Now test the not-found handler by requesting a book ID that does not exist.

  • Run this curl command to request a non-existent book:
curl -s http://localhost:8080/api/books/999 | python3 -m json.tool

What should I see?

You should see a 404 response with "error": "Not Found" and a message field saying "Book not found with id: 999".

  • Verify that your existing CRUD endpoints still work by fetching all books:
curl -s http://localhost:8080/api/books | python3 -m json.tool

You should see your 4 seeded books returned in a paginated response. This confirms the handler only intercepts errors, not successful requests.

Getting a 500 error instead of 400 or 404?

Make sure GlobalExceptionHandler.java is in the com.example.bookstore.exception package. Spring's component scanning only picks up classes in the base package or its sub-packages.

Also check that @RestControllerAdvice is on the class (not @ControllerAdvice). The Rest prefix ensures responses are serialized as JSON.

Help me debug my GlobalExceptionHandler not catching exceptions

Your API now returns clean, parseable error responses for both invalid input and missing resources. In the Secret Mission, you'll extend this API with search and filtering capabilities.

Secret mission

Add Search and Filtering

Your bookstore API can create, read, update, and delete books. But what if a user wants to find all books by a specific author, or browse titles published in a certain decade? In this mission, you'll add a search endpoint that filters books by author, title keyword, or publication year range.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The bookstore project folder on your Desktop.
  • The Spring Boot application running on port 8080.
  • VS Code extensions: Extension Pack for Java and Spring Boot Extension Pack.
  • JDK 21 installed on your machine.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the API further.

  • Your Spring Boot app will stop automatically when you close the terminal or press Ctrl+C.
  • H2 is an in-memory database, so all data resets each time you restart the app.
  • JDK 21 and the VS Code extensions stay installed for future Java projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files and tools stay installed so you can pick up where you left off.

  • Switch to the terminal where the Spring Boot app is running and press Ctrl+C to stop it.
  • Your bookstore folder, JDK, and VS Code extensions remain on your machine for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to fully clean up.

  • Stop the Spring Boot app by pressing Ctrl+C in the terminal where it's running.
  • Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf bookstore
  • On Windows, run this instead:
cd ~\Desktop
rmdir /s /q bookstore
  • Uninstall the VS Code extensions by running:
code --uninstall-extension vscjava.vscode-java-pack
code --uninstall-extension vmware.vscode-boot-dev-pack
  • To remove JDK 21, open Settings then Apps & features (Windows) and uninstall the JDK entry. On macOS, if you installed with Homebrew, run brew uninstall openjdk@21.

Nice Work!

Nice Work!

Nice work! You just built a production-patterned REST API from scratch using Spring Boot, complete with layered architecture, validation, and data persistence.

You've learned how to:

  • Build a full CRUD REST API with Spring Boot, using proper HTTP status codes and response patterns for create, read, update, and delete operations.
  • Implement layered architecture where the Controller delegates to the Service, and the Service uses the Repository, keeping each layer focused on a single responsibility.
  • Add Jakarta Bean Validation with a global exception handler that returns structured JSON error responses with field-level messages.
  • Secret Mission: Added a search endpoint with multi-parameter filtering using Spring Data JPA query method naming conventions.

Ready to quiz yourself?