Build a Book Catalog REST API
Build a Spring Boot REST API that stores books in a database.
Introduction
30 Second Summary
Every app you use talks to a server behind the scenes. When you search for a book online or add one to your cart, those actions travel as requests to an API that processes them and sends back a response.
In this project, you will build a Book Catalog REST API using Spring Boot. Your API will store books in a database, validate every incoming request, and return clean error messages when something goes wrong.
What You'll Build
Picture typing a command in your terminal, watching a new book pop into your database, and then sending bad data on purpose just to see your API reject it with a clear error message.
By the end of this project, you'll have:
- Four REST endpoints that let you create, list, look up, and delete books right from your browser or terminal.
- A live H2 database console where you can see your book data stored in real tables and run SQL queries against it.
- Input validation and error handling that catch blank titles, negative prices, and missing books, responding with clear JSON error messages.
- Secret Mission: Add a search endpoint so users can find books by title or author.
Are there any prerequisites?
No prior Java or Spring Boot experience is needed. This project walks you through installing everything from scratch, including JDK 21 and all VS Code extensions.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Java Development Environment
Your REST API will be written in Java, so your machine needs a Java compiler before you can write a single line of Spring Boot code.
In this step, you will install JDK 21, set up VS Code with Java and Spring Boot extensions, and generate your starter project using Spring Initializr.
In this step, get ready to:
- Install JDK 21 and verify it works.
- Add Java and Spring Boot extension packs to VS Code.
- Generate and run your first Spring Boot project.
Install JDK 21
The JDK (Java Development Kit) is the compiler and runtime your machine needs to build and run Java applications. Spring Boot 4.1.0 requires at least Java 17, and this project uses JDK 21.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Check if Java is already installed by running this command:
java -version
Look at the output in your terminal and pick the tab below that matches what you see.
✔️ I see version 21 or higher
Your terminal shows something like java version "21.0.x" on the first line. JDK 21 is ready to go.
ⓧ I see an older version
You have Java installed, but this project needs version 21. Follow these steps to install JDK 21 alongside your current version.
- Open Oracle's Java Downloads page in your browser.
- Select the JDK 21 tab.
- Under the Windows section, download the x64 Installer (.exe file).
- Run the installer and follow the default prompts until it finishes.
- Back in VS Code, close your current terminal by clicking the trash can icon in the terminal panel.
- Open a fresh terminal by pressing Ctrl+`.
- Run java -version again. You should now see version 21.
Why do I need a new terminal?
The JDK installer updates your system PATH, but terminals that were already open still use the old PATH. Opening a fresh terminal picks up the new JDK location.
ⓧ Command not found
Java is not installed on your machine yet. Follow these steps to install JDK 21.
- Open Oracle's Java Downloads page in your browser.
- Select the JDK 21 tab.
- Under the Windows section, download the x64 Installer (.exe file).
- Run the installer and follow the default prompts until it finishes.
- Back in VS Code, close your current terminal by clicking the trash can icon in the terminal panel.
- Open a fresh terminal by pressing Ctrl+`.
- Run java -version again. You should now see java version "21.0.x" on the first line.
Still not seeing version 21?
Make sure you downloaded the JDK 21 installer specifically, not a different version. The Oracle downloads page lists multiple versions.
If you see an older version after installing, you may need to restart VS Code entirely so it picks up the updated PATH.
Help me troubleshoot my JDK 21 installation on Windows.
Install the Java and Spring Boot extensions
VS Code needs extensions to understand Java syntax, run Maven builds, and work with Spring Boot projects. Two extension packs cover everything you need.
- Open the Extensions panel by pressing Ctrl+Shift+X.
- Type Extension Pack for Java in the search bar.
- Find the result by Microsoft and click Install.
- Clear the search bar.
- Type Spring Boot Extension Pack in the search bar.
- Find the result by VMware and click Install.
You should see both extension packs listed under the Installed section of the Extensions panel.
What do these extensions do?
The Extension Pack for Java adds auto-complete, debugging, and Maven support. The Spring Boot Extension Pack adds Spring Initializr (the project generator you will use next), a Spring Boot dashboard, and smart editing for Spring configuration files.
Multiple results showing up?
Make sure you pick the packs published by Microsoft and VMware specifically. Other extensions with similar names exist but are not the official ones.
Help me find and install the correct Java extensions in VS Code.
Generate your Spring Boot project
Spring Initializr is a project generator that creates a ready-to-run Spring Boot app with the dependencies you choose. The extension you just installed lets you use it directly from VS Code.
- Open the Command Palette by pressing Ctrl+Shift+P.
- Type Spring Initializr: Create a Maven Project and select it from the dropdown.
- Select Spring Boot version 4.1.0.
- Select Java as the project language.
- Enter com.example as the Group Id.
- Enter bookcatalog as the Artifact Id.
- Select Jar as the packaging type.
- Select Java version 21.
What are dependencies?
Dependencies are pre-built libraries that add specific capabilities to your project. Instead of writing everything from scratch, you pick the building blocks you need. The next prompt asks you to choose four.
- When prompted for dependencies, type Spring Web and select it.
- Type Spring Data JPA and select it.
- Type H2 Database and select it.
- Type Validation and select it.
- Press Enter to confirm your selected dependencies.
- A file dialog opens. Select your Desktop as the save location.
- When VS Code asks if you want to open the generated project, click Open.
VS Code reloads with the bookcatalog project open. You should see a folder tree in the Explorer sidebar with folders like src and files like pom.xml.
What did Spring Initializr create?
The pom.xml file lists your project settings and all four dependencies you selected. Maven reads this file to know what to download and build.
The src/main/java folder holds your Java code. Inside it, BookCatalogApplication.java is the entry point that starts your app. The mvnw.cmd file is the Maven Wrapper, which lets you run Maven without installing it separately.
Before you run the app, what do you think will happen? Will the project start on the first try, or will something go wrong?
- Open a terminal in VS Code by pressing Ctrl+`.
- Start the application by running this command:
.\mvnw.cmd spring-boot:run
First run takes a few minutes
Maven downloads all project dependencies from the internet on the first run. This can take 2-5 minutes depending on your connection speed. You will see download progress lines scrolling in the terminal. Subsequent runs will be much faster.
Once the downloads finish, you should see a large Spring Boot banner made of ASCII art. Look for a line near the bottom that says Started BookcatalogApplication. That confirms your app is running.
What does this command do?
The .\mvnw.cmd part runs the Maven Wrapper that came with your generated project. No separate Maven installation needed.
The spring-boot:run part tells Maven to compile your Java code and start the Spring Boot application on localhost:8080.
- Stop the running app by pressing Ctrl+C in the terminal.
App not starting?
Make sure your terminal is open inside the bookcatalog folder. If you see a file-not-found error, the terminal may be pointing to a different directory.
If you see a port-already-in-use error, another application is using port 8080. Close that application and try again.
Help me troubleshoot my Spring Boot app startup error.
Your development environment is ready and your Spring Boot app starts clean. Next up, you will create REST endpoints that accept and return book data.
Create Your First REST Endpoints
In the last step, you set up your Java development environment and confirmed your Spring Boot app starts successfully. Now it's time to make it actually do something useful.
Right now, your app runs but has no endpoints to respond to requests. You'll create a Book class to define what a book looks like as data, then build a controller that handles HTTP requests.
In this step, get ready to:
- Create a Book class to represent book data.
- Build a controller with three REST endpoints.
- Test the API and discover a key limitation.
Create the Book class
Before your API can send or receive book data, you need a Java class that defines what a "book" looks like. This class holds four pieces of information: an ID, a title, an author, and a price.
- In the VS Code Explorer sidebar, expand the folders src/main/java/com/example/bookcatalog.
- Right-click on the bookcatalog folder and click New File.
- Name the file Book.java and press Enter.
- Replace any auto-generated content with the following code:
package com.example.bookcatalog;
public class Book {
// Each field stores one property of a book
private Long id;
private String title;
private String author;
private double price;
// No-argument constructor, required by Spring
public Book() {
}
// Constructor for creating a book with data
public Book(String title, String author, double price) {
this.title = title;
this.author = author;
this.price = price;
}
}
What does this code do?
This class is a data blueprint. Each Book object will hold four fields: an id to identify it, a title, an author, and a price.
The fields are private, meaning other code can't access them directly. The no-argument constructor Book() lets Spring create empty Book objects when it receives JSON data from an API request.
Java classes use getters and setters to let other code read and write private fields. Your Book class needs a getter and setter for each of its four fields.
- In Book.java, place your cursor on the blank line just above the closing } of the class.
- Add the following getter and setter methods:
// Getters and setters let other code read and update 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 double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
What are getters and setters?
Each getter method (like getTitle()) returns the value of a private field. Each setter method (like setTitle()) updates it.
Spring uses these behind the scenes to convert JSON data into Book objects and back again. When your API receives {"title": "Dune"}, Spring calls setTitle("Dune") automatically.
- Save Book.java.
- Check that VS Code shows no red underlines in the file. If you see any, double-check that your getter/setter method names match the field names exactly (e.g. getTitle for title).
Seeing red underlines in Book.java?
- Make sure every opening brace { has a matching closing brace }.
- Confirm the file is saved inside the src/main/java/com/example/bookcatalog/ folder, not somewhere else.
- Check that the package line at the top reads package com.example.bookcatalog; exactly.
help me fix red underlines in my Book.java file
✔️ 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
Scroll up and confirm your Book.java contains these sections in order:
- The package declaration, class declaration, four private fields, and two constructors (no-arg and three-arg).
- Getter and setter methods for all four fields: id, title, author, and price.
Create the BookController
A controller is the part of your app that listens for HTTP requests and decides what to send back. You'll create one that maps to the path /api/books and handles three types of requests.
- In the Explorer sidebar, right-click on the bookcatalog folder again.
- Click New File and name it BookController.java.
- Replace any auto-generated content with the following code:
package com.example.bookcatalog;
import java.util.ArrayList;
import java.util.List;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Tells Spring this class handles HTTP requests and returns JSON
@RestController
// All endpoints in this controller start with /api/books
@RequestMapping("/api/books")
public class BookController {
// In-memory list to store books (no database yet)
private final List<Book> books = new ArrayList<>();
// Counter to give each new book a unique ID
private Long nextId = 1L;
// GET /api/books - returns the full list of books
@GetMapping
public List<Book> getAllBooks() {
return books;
}
}
What does this code do?
- @RestController tells Spring Boot that this class handles web requests and automatically converts return values to JSON.
- @RequestMapping("/api/books") sets the base URL path. Every endpoint in this controller starts with /api/books.
- The ArrayList acts as temporary storage. Books live in this list while the app is running.
- @GetMapping on getAllBooks() means: when someone sends a GET request to /api/books, return the full list.
Your controller needs two more methods: one to find a single book by its ID, and one to create a new book.
- In BookController.java, place your cursor on the blank line just above the closing } of the class.
- Add the following two methods:
// GET /api/books/{id} - finds one book by its ID
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return books.stream()
.filter(b -> b.getId().equals(id))
.findFirst()
.orElse(null);
}
// POST /api/books - creates a new book from JSON in the request body
@PostMapping
public Book createBook(@RequestBody Book book) {
book.setId(nextId++);
books.add(book);
return book;
}
What do these methods do?
- getBookById() uses @GetMapping("/{id}") to respond to URLs like /api/books/1. The @PathVariable annotation pulls the number from the URL and passes it as the id parameter.
- createBook() uses @PostMapping to accept new data. The @RequestBody annotation tells Spring to convert the incoming JSON into a Book object automatically.
- nextId++ assigns a unique ID to each new book, then increments the counter for the next one.
- Save BookController.java.
- Check that VS Code shows no red underlines in the file.
Seeing red underlines in BookController.java?
- Make sure all ten import statements are present at the top of the file. Missing imports are the most common cause.
- Confirm that Book.java is saved and has no errors of its own. The controller depends on the Book class.
- If VS Code still shows errors, try closing and reopening the file. The Java language server sometimes needs a moment to catch up.
help me fix red underlines in my BookController.java file
✔️ Awesome, I've got everything!
Great. Double-check you've saved BookController.java before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your BookController.java contains these sections in order:
- The package declaration, ten import statements, @RestController, @RequestMapping("/api/books"), and the class declaration with the books list and nextId counter.
- The getAllBooks() method with @GetMapping.
- The getBookById() method with @GetMapping("/{id}") and the stream filter logic.
- The createBook() method with @PostMapping and @RequestBody.
Test your endpoints and discover a limitation
Your code is ready. Time to restart the app and see your REST API in action.
- Switch back to the VS Code terminal where your app is running.
- Press Ctrl+C to stop the app.
- Restart the app by running this command:
.\mvnw.cmd spring-boot:run
- Wait until you see Started BookcatalogApplication in the terminal output.
Why do I have to restart?
Spring Boot compiles and loads your code when it starts up. Any new files you added (like Book.java and BookController.java) won't be picked up until you restart.
Before you test, think about this: you haven't created any books yet. What do you think the API will return if you ask for the full list right now?
- Open your browser and go to http://localhost:8080/api/books.
You should see an empty JSON array: []. That's your getAllBooks() method returning an empty list.
Now add a book. You'll need a second terminal because the first one is running your server.
- Open a new terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- 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","price":10.99}'
What does this command do?
- curl.exe sends HTTP requests from the terminal. On Windows, you must use curl.exe instead of curl because PowerShell has its own curl alias that behaves differently.
- -X POST tells it to send a POST request (creating data, not just reading it).
- -H "Content-Type: application/json" tells the server to expect JSON in the request body.
- -d is followed by the JSON data for your new book.
You should see the book echoed back with an id of 1 added to it. That's your createBook() method at work.
- Switch back to your browser and refresh the http://localhost:8080/api/books page.
You should now see your book in the list instead of an empty array.
- Add a second book by running this command in your second terminal:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Dune","author":"Frank Herbert","price":14.99}'
- Test the GET-by-ID endpoint by running this command:
curl.exe http://localhost:8080/api/books/2
You should see only the Dune book returned, because its id is 2.
Your API works. But there's a catch. What do you think will happen to your books if you restart the server?
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Restart the app by running:
.\mvnw.cmd spring-boot:run
- Wait for Started BookcatalogApplication to appear, then refresh http://localhost:8080/api/books in your browser.
Your books are gone. The list is back to [].
Why did my books disappear?
Your books were stored in an ArrayList inside the controller class. That list exists only in your computer's memory while the app is running.
When you stopped the server, Java threw away all the objects in memory, including your list of books. This is exactly the problem a database solves. You'll fix this in the next step.
You've built a working REST API with three endpoints. In the next step, you'll connect to a real database so your books survive a restart.
Connect to a Real Database
You have a working API with three endpoints that can create and retrieve books. But you saw the problem at the end of the last step: restart the server, and every book you added is gone.
That happens because your books live in a Java ArrayList that only exists while the app is running. In this step, you will connect your app to a real database and reorganize your code into three clean layers: Controller, Service, and Repository.
In this step, get ready to:
- Convert your Book class into a database entity and create a repository.
- Build a service layer that holds your business logic.
- Rewrite your controller to use the service layer and test with the H2 database console.
Tell Spring that Book is a database table
Right now, Book.java is a plain Java class. Spring has no idea it should be stored in a database. JPA annotations tell Spring to create a database table from your class automatically.
- In VS Code, open Book.java from the src/main/java/com/example/bookcatalog folder.
- Find these lines at the top of the file:
package com.example.bookcatalog;
public class Book {
private Long id;
- Replace those lines with the following code, which adds four import statements, an @Entity annotation on the class, and @Id plus @GeneratedValue annotations on the id field:
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;
What do these annotations do?
@Entity tells Spring to create a BOOK table in the database that mirrors this class. Each field becomes a column.
@Id marks the id field as the table's primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to assign a new, unique ID automatically whenever you create a book.
- Leave the rest of Book.java unchanged (the constructors, getters, and setters stay exactly as they are).
- Save Book.java.
Now you need a repository. A repository is an interface that gives you database operations (save, find, delete) without writing any SQL.
- Create a new file called BookRepository.java in the same folder as Book.java (src/main/java/com/example/bookcatalog) and paste the following code:
package com.example.bookcatalog;
import org.springframework.data.jpa.repository.JpaRepository;
// Extends JpaRepository to get save, findAll, findById, deleteById for free
public interface BookRepository extends JpaRepository<Book, Long> {
}
What does this single line give you?
By extending JpaRepository<Book, Long>, Spring automatically provides methods like save(), findAll(), findById(), and deleteById(). You get full database CRUD operations with zero SQL.
The <Book, Long> part tells Spring this repository manages Book entities and uses Long as the ID type.
- Save BookRepository.java.
- Check the Explorer sidebar in VS Code. You should see BookRepository.java listed alongside Book.java and BookController.java in the bookcatalog package.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both Book.java and BookRepository.java.
ⓧ I'd like to double check the full code
Here is the full BookRepository.java:
package com.example.bookcatalog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
For Book.java, confirm that the top of your file now has the four jakarta.persistence imports, @Entity above the class, and @Id plus @GeneratedValue above the id field. Everything else in Book.java (constructors, getters, setters) stays the same as before.
Don't see BookRepository.java in the sidebar?
Make sure you created the file inside src/main/java/com/example/bookcatalog, the same package as Book.java. If it is in the wrong folder, Spring will not detect it.
help me create BookRepository.java in the correct package
Create the service layer and configure the database
Your controller currently does everything: it holds the data, manages IDs, and handles requests. The Controller-Service-Repository pattern splits these responsibilities into three layers so each one has a single job.
What is Controller-Service-Repository?
The Controller receives HTTP requests and returns responses. It does not contain business logic or database code.
The Service holds business logic. It decides what to do with data (look up a book, throw an error if not found) and calls the repository.
The Repository talks to the database. You already created BookRepository in the previous substep.
- Create a new file called BookService.java in src/main/java/com/example/bookcatalog and paste the following code:
package com.example.bookcatalog;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository bookRepository; // Database access layer
public BookService(BookRepository bookRepository) { // Constructor injection
this.bookRepository = bookRepository;
}
public List<Book> getAllBooks() { // Returns every book from the database
return bookRepository.findAll();
}
public Book getBookById(Long id) { // Throws error if book not found
return bookRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
}
public Book createBook(Book book) { // Saves a new book to the database
return bookRepository.save(book);
}
public void deleteBook(Long id) { // Removes a book by ID
bookRepository.deleteById(id);
}
}
What does this service do?
@Service tells Spring to manage this class as a component. Spring automatically creates an instance and wires it wherever it is needed.
The constructor takes BookRepository as a parameter. Spring sees this and injects the repository automatically. This pattern is called constructor injection.
The getBookById() method uses .orElseThrow() so that looking up a nonexistent book throws an error instead of silently returning nothing.
- Save BookService.java.
You also need to tell Spring where your database lives and enable the H2 web console so you can inspect your data.
- Open application.properties in src/main/resources.
- Find this line:
spring.application.name=bookcatalog
- Add these two lines directly below it:
spring.datasource.url=jdbc:h2:mem:bookcatalog
spring.h2.console.enabled=true
What do these properties do?
spring.datasource.url=jdbc:h2:mem:bookcatalog tells Spring to use an in-memory H2 database named bookcatalog. The data lives in memory while the app runs, but it is stored in real database tables instead of a Java list.
spring.h2.console.enabled=true turns on a web-based database viewer at /h2-console so you can run SQL queries and see your data.
- Save application.properties.
- Switch back to your terminal. Stop the running app by pressing Ctrl+C.
- Restart the app by running:
.\mvnw.cmd spring-boot:run
- Watch the terminal output. You should see the Spring Boot banner followed by Started BookcatalogApplication with no errors.
Seeing an error on startup?
If you see an error mentioning a bean that could not be found, make sure BookRepository.java and BookService.java are in the com.example.bookcatalog package (the same folder as BookCatalogApplication.java). Spring only scans that package and its sub-packages automatically.
If you see a JPA mapping error, double-check that Book.java has @Entity above the class and @Id above the id field.
help me fix my Spring Boot startup error after adding JPA and the service layer
Rewrite the controller and test your API
The old controller managed an ArrayList and a nextId counter directly. Now that you have a service layer, the controller's only job is to receive requests and pass them to BookService.
- Open BookController.java.
- Select all the code in the file (Ctrl+A) and delete it.
- Paste the new controller structure with its imports and constructor:
package com.example.bookcatalog;
import java.util.List;
import org.springframework.http.HttpStatus;
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.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 BookService bookService; // Service layer handles logic
// Spring injects BookService automatically
public BookController(BookService bookService) {
this.bookService = bookService;
}
}
What changed from the old controller?
The ArrayList<Book> and nextId counter are gone. In their place, the controller holds a single BookService field. Spring creates the BookService instance and passes it into the constructor automatically.
The controller no longer knows anything about the database or how data is stored. It just delegates to the service.
- Add the four endpoint methods inside the BookController class, directly above the closing } brace:
@GetMapping
public List<Book> getAllBooks() {
return bookService.getAllBooks();
}
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return bookService.getBookById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // Returns 201 status for new resources
public Book createBook(@RequestBody Book book) {
return bookService.createBook(book);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // Returns 204 status after deletion
public void deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
}
What's new in these endpoints?
Each method now calls bookService instead of managing the list directly. The stream().filter() code from before is replaced by a single bookService.getBookById(id) call.
Two new annotations appear: @ResponseStatus(HttpStatus.CREATED) makes the POST endpoint return a 201 status code (the standard code for "resource created"). @ResponseStatus(HttpStatus.NO_CONTENT) makes the DELETE endpoint return 204 ("deleted, nothing to return").
There is also a brand new deleteBook() endpoint mapped to DELETE /api/books/{id} that was not in the old controller.
- Save BookController.java.
✔️ Awesome, I've got everything!
Great. Double check you have saved BookController.java, BookService.java, BookRepository.java, Book.java, and application.properties.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in your files, in order:
- Book.java: four jakarta.persistence imports at the top, @Entity above the class, @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) above the id field. Everything else unchanged.
- BookRepository.java: an interface extending JpaRepository<Book, Long> with no methods inside.
- BookService.java: @Service class with a BookRepository field, constructor, and four methods: getAllBooks(), getBookById(), createBook(), deleteBook().
- BookController.java: @RestController with a BookService field, constructor, and four endpoint methods that delegate to the service.
- application.properties: three lines total:
spring.application.name=bookcatalog
spring.datasource.url=jdbc:h2:mem:bookcatalog
spring.h2.console.enabled=true
- Stop the running app (Ctrl+C) and restart it by running:
.\mvnw.cmd spring-boot:run
Before you test, predict what will happen: if you POST a book and then check the H2 database console, do you think the book will appear in a real database table?
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- Create a book by running:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Great Gatsby\",\"author\":\"F. Scott Fitzgerald\",\"price\":10.99}"
You should see a JSON response with your book and an auto-generated id value.
- Confirm the book is in the list by running:
curl.exe http://localhost:8080/api/books
You should see an array with one book in it. So far, this looks the same as the old ArrayList version. The difference is where that data actually lives.
- Open your browser and go to:
http://localhost:8080/h2-console
- In the JDBC URL field, replace whatever is there with:
jdbc:h2:mem:bookcatalog
- In the User Name field, enter sa.
- Leave the Password field blank.
- Click Connect.
You should see a sidebar listing the BOOK table. Spring created this table from your @Entity annotations.
- Type this SQL query into the text area and click Run:
SELECT * FROM BOOK;
You should see your book ("The Great Gatsby") displayed as a row in a real database table. Your data is no longer stuck in a Java list.
H2 console not loading or showing an empty table?
If the H2 console page does not load, make sure spring.h2.console.enabled=true is in your application.properties and you restarted the app after adding it.
If you connected but do not see the BOOK table, check that the JDBC URL field says jdbc:h2:mem:bookcatalog exactly. The default value is jdbc:h2:mem:testdb, which connects to a different (empty) database.
help me connect to the H2 database console in my Spring Boot project
Your API now stores data in a real database, and your code is organized into three clean layers. Next up, you will add validation and error handling so your API rejects bad data and returns proper error messages.
Add Validation and Error Handling
In the previous step, you connected your API to a real H2 database and organized your code into the Controller-Service-Repository pattern. Your books now persist across requests.
There are two problems hiding in plain sight. Your API happily accepts garbage data like blank titles and negative prices. It also returns ugly, unhelpful error pages when you look up a book that does not exist.
In this step, get ready to:
- Add validation annotations that reject incomplete or nonsensical book entries.
- Create a custom exception and global error handler for clean JSON error responses.
- Test that bad input returns clear 400 errors and missing books return clean 404 errors.
Try creating a book with bad data
Before you change any code, try sending the API some garbage. What do you think will happen if you POST a book with an empty title, an empty author, and a negative price?
- Switch to the terminal where you ran curl.exe commands earlier and POST a book with empty fields and a negative price by running this command:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\": \"\", \"author\": \"\", \"price\": -5}"
The API returns a 201 Created response and saves the garbage data to the database. An empty title, an empty author, and a price of negative five are all perfectly fine as far as your API is concerned.
- Now try requesting a book that does not exist by running:
curl.exe http://localhost:8080/api/books/999
You get a 500 Internal Server Error response. The API crashed trying to find a book that does not exist and gave you a generic error with no useful information.
What are the two problems here?
Problem one: the API accepts any data, even when it makes no sense. A real API should reject blank titles and negative prices before they reach the database.
Problem two: when something goes wrong, the API crashes with a generic 500 error instead of returning a helpful message like "Book not found." These are exactly the gaps you will close in this step.
Add validation annotations to your Book class
Spring Boot includes a validation library that lets you declare rules directly on your fields using annotations. The library checks every incoming request against those rules before your code runs.
- Return to Book.java in VS Code and find the import block at the top of the file. Add these two imports after the existing jakarta.persistence imports:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
- Now scroll down to the three field declarations. Find these lines:
private String title;
private String author;
private double price;
- Add a validation annotation above each field so they look like this:
// Rejects blank or empty titles
@NotBlank(message = "Title is required")
private String title;
// Rejects blank or empty authors
@NotBlank(message = "Author is required")
private String author;
// Rejects zero or negative prices
@Positive(message = "Price must be greater than zero")
private double price;
What do these annotations do?
@NotBlank rejects strings that are null, empty, or contain only whitespace. @Positive rejects numbers that are zero or negative. The message inside each annotation is the error text the API sends back when validation fails.
- Save Book.java.
The annotations define the rules, but Spring does not enforce them automatically. You need to tell the controller to validate incoming data before processing it.
- Open BookController.java and add this import after the existing import lines:
import jakarta.validation.Valid;
- Find the createBook method signature. It currently looks like this:
public Book createBook(@RequestBody Book book) {
- Add @Valid before @RequestBody so the line looks like this:
public Book createBook(@Valid @RequestBody Book book) {
What does @Valid do?
@Valid tells Spring to run all the validation annotations on the Book object before the method executes. If any rule fails, Spring rejects the request immediately and never calls createBook() at all.
- Save BookController.java.
- Switch to the terminal running your app. Stop it with Ctrl+C and restart it by running:
.\mvnw.cmd spring-boot:run
What do you think will happen now if you POST that same garbage book with an empty title? Will the API still accept it?
- Switch to your second terminal and try the same bad POST again by running:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\": \"\", \"author\": \"\", \"price\": -5}"
This time the API rejects the request. You see a 400 Bad Request response with field-level error messages telling you exactly what is wrong.
Still seeing a 201 response?
- Make sure you saved both Book.java and BookController.java before restarting.
- Check that you added @Valid directly before @RequestBody in the createBook method. Without @Valid, Spring ignores the annotations entirely.
- Verify the import jakarta.validation.Valid is present at the top of BookController.java.
Help me debug why my validation annotations are not working in Spring Boot.
Validation is working. But remember the second problem? Try requesting a nonexistent book again and you will still see a 500 Internal Server Error. That is what you will fix next.
Create a custom exception and error handler
Right now, when a book is not found, your service throws a generic RuntimeException. Spring does not know how to turn that into a helpful response, so it returns a 500 crash page. You need a dedicated exception class and a handler that catches it and returns a proper 404.
- Create a new file called BookNotFoundException.java in the same folder as your other Java files (src/main/java/com/example/bookcatalog/) and paste in this code:
package com.example.bookcatalog;
// Custom exception for 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 it is a custom error type. The constructor takes a book ID and builds a clear message like "Book not found with id: 5". Having a named exception lets you write a handler that catches only this specific error.
- Save BookNotFoundException.java.
Now update your service to throw this new exception instead of the generic one.
- Open BookService.java and find the getBookById method. Locate this line:
.orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
- Replace it with this line, which throws your new custom exception:
.orElseThrow(() -> new BookNotFoundException(id));
- Next, find the deleteBook method. It currently looks like this:
public void deleteBook(Long id) {
bookRepository.deleteById(id);
}
- Replace the entire deleteBook method with this version, which checks if the book exists before deleting:
public void deleteBook(Long id) {
// Check if the book exists before attempting to delete
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException(id);
}
bookRepository.deleteById(id);
}
Why check existence before deleting?
Without the check, deleteById() silently does nothing if the ID does not exist. Adding the existsById() check means your API tells the caller when they try to delete a book that is not there, instead of pretending the delete succeeded.
- Save BookService.java.
- Restart the app to verify it compiles. Stop it with Ctrl+C and run:
.\mvnw.cmd spring-boot:run
You should see the Spring Boot banner and the "Started BookcatalogApplication" message. The app compiles and runs, which means your exception class and service changes are wired up correctly.
The custom exception exists, but Spring still does not know how to turn it into a clean JSON response. You need a global error handler to catch exceptions and format them.
- Create a new file called GlobalExceptionHandler.java in the same src/main/java/com/example/bookcatalog/ folder and paste in this code:
package com.example.bookcatalog;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Catches BookNotFoundException and returns a 404
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, String> handleBookNotFound(BookNotFoundException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", ex.getMessage());
return error;
}
// Catches validation failures and returns a 400
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fieldError ->
errors.put(fieldError.getField(), fieldError.getDefaultMessage())
);
return errors;
}
}
What does this handler do?
- @RestControllerAdvice tells Spring this class handles exceptions for all controllers in the app.
- handleBookNotFound() catches your custom BookNotFoundException and returns a 404 with a JSON body containing the error message.
- handleValidationErrors() catches validation failures from @Valid and returns a 400 with a map of field names to error messages.
- Save GlobalExceptionHandler.java.
Seeing a compile error?
- Check that the package line at the top of every new file reads package com.example.bookcatalog; and matches the folder path.
- Verify that BookNotFoundException.java is saved. BookService.java cannot compile without it because it references BookNotFoundException.
Help me fix a compile error in my Spring Boot project.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all your files before restarting the app.
ⓧ I'd like to double check the full code
Scroll up and confirm each piece is in place: the two validation imports and three field annotations in Book.java, the @Valid import and annotation in BookController.java, the BookNotFoundException class, the updated getBookById and deleteBook methods in BookService.java, and the GlobalExceptionHandler class.
Here is the full BookService.java for reference:
package com.example.bookcatalog;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> getAllBooks() {
return bookRepository.findAll();
}
public Book getBookById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
public Book createBook(Book book) {
return bookRepository.save(book);
}
public void deleteBook(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException(id);
}
bookRepository.deleteById(id);
}
}
Test your validation and error handling
- Stop the running app with Ctrl+C and restart it by running:
.\mvnw.cmd spring-boot:run
Time to put everything to the test. Before you run each command, predict what status code and message you expect to see.
- Test validation by POSTing a book with an empty title. Switch to your second terminal and run:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\": \"\", \"author\": \"Someone\", \"price\": 10}"
You see a 400 Bad Request response with {"title": "Title is required"}. The field name and the exact error message you defined in @NotBlank are right there in the response.
- Test the 404 handler by requesting a book that does not exist. Run:
curl.exe http://localhost:8080/api/books/999
This time, instead of a 500 crash, you see a clean 404 Not Found response with {"error": "Book not found with id: 999"}. The error handler caught the exception and turned it into a helpful message.
- Finally, confirm that valid requests still work. POST a real book by running:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\": \"The Great Gatsby\", \"author\": \"F. Scott Fitzgerald\", \"price\": 12.99}"
You get a 201 Created response with the saved book and its generated ID. Good data still flows through cleanly.
Still seeing a 500 error for missing books?
- Open BookService.java and confirm the getBookById method throws new BookNotFoundException(id), not new RuntimeException(...).
- Verify that GlobalExceptionHandler.java has the @RestControllerAdvice annotation on the class. Without it, Spring does not register the handler.
- Make sure the file is in the same package (com.example.bookcatalog) as your other classes. Spring only scans this package by default.
Help me debug why my GlobalExceptionHandler is not catching BookNotFoundException.
Your REST API now validates input, handles errors gracefully, and returns clean JSON in every scenario. You have a production-quality API pattern you can show in any interview.
Secret mission
Add a Search Endpoint
Your API can create, list, and delete books. But what if someone has hundreds of books and wants to find one by title? In this secret mission, you will add a search endpoint that lets users filter their book collection by title or author, powered by Spring Data JPA's derived query methods.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine with an embedded in-memory H2 database. There are no cloud services, no paid accounts, and no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The bookcatalog project folder on your machine.
- JDK 21 installed on your system.
- Extension Pack for Java and Spring Boot Extension Pack installed in VS Code.
Keep everything running
No action needed. Choose this if you're still actively building or want to revisit the project soon.
- Your bookcatalog project folder and all your code stay on your machine.
- The H2 database is in-memory, so it resets every time you stop the app. No persistent data accumulates.
- JDK 21 and the VS Code extensions remain installed and ready for your next Java project.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your code is saved and ready to restart anytime.
- In the terminal where your Spring Boot app is running, press Ctrl+C to stop the server.
- When you're ready to come back, open the bookcatalog folder in VS Code and run:
.\mvnw.cmd spring-boot:run
Delete - I don't want to use this again
Remove the project files and optionally uninstall JDK 21 from your system.
- Stop the running server by pressing Ctrl+C in the terminal.
- Delete the bookcatalog project folder. In File Explorer, navigate to where you saved it, right-click the folder, and select Delete.
- To uninstall JDK 21, open Settings, go to Apps, then Installed apps. Search for Java, click the three-dot menu next to the JDK 21 entry, and select Uninstall.
- To uninstall the VS Code extensions, open the Extensions panel (Ctrl+Shift+X), find Extension Pack for Java and Spring Boot Extension Pack, and click Uninstall on each.
Nice Work!
Nice Work!
Nice work! You just built a production-style REST API from scratch using Spring Boot 4.1.0 and Java. Your Book Catalog API creates, lists, retrieves, and deletes books with proper validation and clean error handling.
You've learned how to:
- Build a REST API with four endpoints (GET all, GET by ID, POST, DELETE) and connect it to an H2 database using Spring Data JPA with zero SQL.
- Implement the Controller-Service-Repository layered architecture pattern, separating your API routing, business logic, and data access into distinct layers.
- Add input validation with Jakarta Bean Validation annotations and a global exception handler that returns clean JSON error responses instead of stack traces.
- Secret Mission: Add a search endpoint with custom Spring Data JPA derived query methods that find books by title or author.
Ready to quiz yourself?