Build a Book Catalog REST API
Build a Spring Boot REST API backed by PostgreSQL with full CRUD operations.
Introduction
30 Second Summary
Every time you search for a product online, add an item to your cart, or check your order status, a REST API is working behind the scenes. REST APIs are how apps talk to servers and databases to create, read, update, and delete data.
In this project, you will build a Book Catalog REST API using Spring Boot and PostgreSQL. You will create endpoints that let you manage a collection of books, with data that persists in a real database.
What You'll Build
A Book Catalog REST API backed by PostgreSQL that lets you create, read, update, and delete books through HTTP requests tested from your terminal.
By the end of this project, you'll have:
- A fully functional Spring Boot application with 5 REST endpoints connected to a PostgreSQL database.
- A properly layered codebase with separate entity, repository, service, controller, and exception packages.
- Input validation and global error handling that returns clean JSON error responses.
- Secret Mission: Add search and filtering so users can find books by author or title using Spring Data JPA query methods.
Are there any prerequisites?
You should have basic familiarity with Java syntax (variables, classes, methods). No prior Spring experience is needed.
Set Up Your Development Environment
Before you can build your REST API, you need three core tools installed on your machine.
Java is the language your Spring Boot application runs on. VS Code gives you a code editor with Java support. PostgreSQL is the database that stores your book data.
In this step, get ready to:
- Install Java 21 and verify it works.
- Install VS Code with Java extensions.
- Install PostgreSQL and create your project database.
Install Java 21
Spring Boot 4 requires Java 17 or higher. You will install Java 21, the latest long-term support (LTS) release from OpenJDK.
- Open your terminal.
- Update your package list and install OpenJDK 21 by running these commands:
sudo apt update
sudo apt install openjdk-21-jdk
What do these commands do?
- sudo apt update refreshes the list of available packages so your system knows what is available to install.
- sudo apt install openjdk-21-jdk downloads and installs the full Java 21 Development Kit, which includes the compiler and runtime.
- When prompted to confirm the install, type Y and press Enter.
- Verify Java is installed by running:
java -version
✔️ I see version 21 or higher
You should see output that includes openjdk version "21 followed by a build number. Java is ready to go.
ⓧ I see an older version
If you see a version lower than 21, you may have multiple Java versions installed.
- Switch to Java 21 by running:
sudo update-alternatives --config java
- Select the option that points to java-21-openjdk and press Enter.
- Run java -version again to confirm it now shows version 21.
ⓧ Command not found
If you see a "command not found" error, the install may not have completed successfully.
- Re-run the install command:
sudo apt install openjdk-21-jdk
- If it still fails, check your internet connection and try sudo apt update first.
- Run java -version again after the install finishes.
Install VS Code and the Java extension pack
VS Code is a lightweight code editor that becomes a full Java IDE when you add the right extensions.
- Install VS Code using Snap by running:
sudo snap install --classic code
What does --classic mean?
The --classic flag gives VS Code full access to your filesystem. Without it, VS Code would run in a sandbox and could not read your project files.
- Verify the installation by running:
code --version
- You should see a version number printed in the terminal.
- Install the Extension Pack for Java by running:
code --install-extension vscjava.vscode-java-pack
What is the Extension Pack for Java?
This extension pack bundles several tools together: language support, debugging, test running, Maven integration, and IntelliSense for Java. It turns VS Code into a capable Java development environment.
Seeing "snap not found"?
If your system does not have Snap pre-installed, install it first with sudo apt install snapd and then retry the VS Code install command.
help me install VS Code on my Linux machine
Install PostgreSQL and create the project database
PostgreSQL is the relational database that will store your book records. You need to install it, start the service, and create a dedicated database and user for this project.
- Install PostgreSQL by running:
sudo apt install postgresql
- When prompted, type Y and press Enter to confirm.
- Start and enable the PostgreSQL service so it runs on boot:
sudo systemctl start postgresql
sudo systemctl enable postgresql
- Verify the service is running:
sudo systemctl status postgresql
- You should see active (exited) or active (running) in the output. Press q to exit the status view.
Now create a database user and database specifically for this project.
- Create a PostgreSQL user named bookapi by running:
sudo -u postgres createuser --pwprompt bookapi
- When prompted for a password, type bookapi and press Enter.
- Type bookapi again to confirm the password.
- Create a database named bookapi and assign it to the new user:
sudo -u postgres createdb --owner=bookapi bookapi
What do these commands do?
sudo -u postgres runs the command as the built-in PostgreSQL admin user. createuser --pwprompt bookapi creates a new database user and asks you to set a password.
createdb --owner=bookapi bookapi creates a new database and grants the bookapi user full control over it. Your Spring Boot app will connect to this database using these credentials.
- Verify the database was created by running:
sudo -u postgres psql -c "\l"
- You should see bookapi in the list of databases with bookapi as the owner.
Database creation failed?
Make sure the PostgreSQL service is running with sudo systemctl status postgresql. If you see an error about the user already existing, that means you already ran the createuser command previously. You can proceed to the createdb step.
help me troubleshoot my PostgreSQL database setup
Your development environment is ready. Next up, you will generate a Spring Boot project and connect it to this database.
Generate and Run the Spring Boot Project
In Step 1, you installed Java, VS Code, and PostgreSQL. Now it is time to create the actual Spring Boot project.
Spring Boot projects start from a project generator called Spring Initializr. It bundles your chosen dependencies into a ready-to-run Maven project. In this step, you will generate the project, configure the database connection, and confirm everything starts up cleanly.
In this step, get ready to:
- Generate a Spring Boot project with all required dependencies.
- Configure the PostgreSQL database connection.
- Run the application and confirm it starts on port 8080.
Generate the project with Spring Initializr
Spring Initializr is a web service that assembles a starter project for you. Instead of clicking through a web form, you can hit its API directly from the terminal using curl.
- Move to your Desktop by running this command:
cd ~/Desktop
- Generate your Spring Boot project by running this command:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=4.0.6 \
-d javaVersion=21 \
-d groupId=com.example \
-d artifactId=bookapi \
-d name=bookapi \
-d packageName=com.example.bookapi \
-d dependencies=web,data-jpa,postgresql,validation,devtools \
-d baseDir=bookapi \
-o bookapi.zip
What does this command do?
This curl command sends a request to Spring Initializr's API at start.spring.io. The -d flags set project options.
- bootVersion=4.0.6 pins Spring Boot to the latest stable release.
- dependencies=web,data-jpa,postgresql,validation,devtools includes Spring Web, Spring Data JPA, the PostgreSQL driver, Bean Validation, and DevTools.
- baseDir=bookapi creates a bookapi folder inside the zip so you get a tidy project directory when you unzip.
- Unzip the downloaded file and move into the project folder by running these commands:
unzip bookapi.zip
cd bookapi
- Confirm the project files exist by running:
ls
You should see files like pom.xml, mvnw, and a src directory.
Seeing a connection error from curl?
- Make sure you have an active internet connection. The command needs to reach start.spring.io.
- If you see a certificate error, try running sudo apt install ca-certificates and try again.
Why can't I download my Spring Boot project from Spring Initializr?
- Open the project in VS Code by running:
code .
What is the Maven wrapper?
The generated project includes mvnw (the Maven wrapper). This is a script that downloads and runs the correct version of Maven for your project. You do not need to install Maven separately.
Configure the database connection
Spring Boot needs to know where your PostgreSQL database lives and how to authenticate. You provide this through the application.properties file.
- In VS Code's Explorer sidebar, expand src > main > resources.
- Click on application.properties to open it.
- Replace any existing content with the following configuration:
spring.application.name=bookapi
spring.datasource.url=jdbc:postgresql://localhost:5432/bookapi
spring.datasource.username=bookapi
spring.datasource.password=bookapi
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
What does this configuration do?
- spring.datasource.url tells Spring Boot to connect to your local PostgreSQL database named bookapi on port 5432.
- spring.datasource.username and spring.datasource.password are the credentials you created in Step 1.
- spring.jpa.hibernate.ddl-auto=create tells Hibernate to create database tables automatically from your Java entities. This is perfect for learning. In production, you would use a migration tool instead.
- spring.jpa.show-sql=true prints every SQL query to the console so you can see what Hibernate does behind the scenes.
- Save application.properties.
Not sure which file to edit?
The file path is src/main/resources/application.properties. Spring Initializr generates this file with a single line (spring.application.name=bookapi). You are replacing it with the full database configuration.
Help me find application.properties in my Spring Boot project.
Run the application
With the database configured, you can start the application. The Maven wrapper compiles your code, resolves dependencies, and launches an embedded Tomcat server.
- Start the Spring Boot application by running this command in your terminal (make sure you are in the bookapi folder):
./mvnw spring-boot:run
What happens when you run this?
The first run downloads all project dependencies, which may take a minute or two. After that, Spring Boot starts an embedded Tomcat server and connects to your PostgreSQL database.
Look for a line in the output that says the application started on port 8080. You will also see Hibernate SQL output confirming the database connection is active.
- Verify the application is running by opening a new terminal tab and running:
curl http://localhost:8080
You should see an error page response (a JSON or HTML 404). This is expected because you have not added any endpoints yet. The important thing is that the server responded, which proves it is running.
Application fails to start?
- If you see a connection refused error for PostgreSQL, make sure the PostgreSQL service is running. Run sudo systemctl start postgresql to start it.
- If you see an authentication error, double-check that the username and password in application.properties match the database user you created in Step 1 (bookapi / bookapi).
- If you see a "permission denied" error on mvnw, make it executable by running chmod +x mvnw.
Why won't my Spring Boot application connect to PostgreSQL?
✔️ Awesome, I've got everything!
Great. Make sure the application stays running in your terminal for the next steps. You can stop it anytime with Ctrl+C.
ⓧ I'd like to double check the full code
Here is the application.properties file you should have:
spring.application.name=bookapi
spring.datasource.url=jdbc:postgresql://localhost:5432/bookapi
spring.datasource.username=bookapi
spring.datasource.password=bookapi
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
And here is the main application class (generated by Spring Initializr, you should not need to change it):
package com.example.bookapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BookapiApplication {
public static void main(String[] args) {
SpringApplication.run(BookapiApplication.class, args);
}
}
Your Spring Boot project is alive and connected to PostgreSQL. Next up, you will create the Book entity and repository so your API has something to store.
Create the Book Entity and Repository
Your Spring Boot application is running and connected to PostgreSQL. The next step is giving it something to store.
In this step, you will create the data layer for your API. This means defining a Book class that maps directly to a database table, and a repository interface that provides all the database operations you need.
In this step, get ready to:
- Create a JPA entity that maps a Java class to a PostgreSQL table.
- Create a repository interface that gives you full CRUD operations with zero boilerplate.
- Run the application and confirm Hibernate creates the table automatically.
Create the Book entity class
A JPA entity is a plain Java class that maps to a database table. Each instance of the class represents one row in the table, and each field represents a column.
- In the VS Code Explorer sidebar, expand src/main/java/com/example/bookapi.
- Right-click on the bookapi folder and select New Folder.
- Name the folder entity and press Enter.
- Right-click on the new entity folder and select New File.
- Name the file Book.java and press Enter.
- Copy the following code into Book.java:
package com.example.bookapi.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
// Maps this class to the "books" table in PostgreSQL
@Entity
@Table(name = "books")
public class Book {
// Primary key with auto-incrementing ID
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
private int year;
// JPA requires a no-arg constructor to create instances from database rows
protected Book() {
}
// Constructor for creating new Book objects in your code
public Book(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
// Getters and setters for all fields
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getYear() {
return year;
}
public void setYear(int year) {
this.year = year;
}
}
What does this code do?
The @Entity annotation tells Spring Data JPA that this class represents a database table. @Table(name = "books") specifies the exact table name in PostgreSQL.
The @Id annotation marks id as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells PostgreSQL to auto-increment the ID for each new row.
JPA requires a no-arg constructor (protected Book()) so Hibernate can create instances when reading from the database. The public constructor is for your own code when creating new books.
- Save Book.java.
You won't see a change yet. The next file you create will complete the data layer and let you verify everything together.
✔️ Awesome, I've got everything!
Great. Make sure you have saved Book.java before moving on.
ⓧ I'd like to double check the full code
package com.example.bookapi.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
private int year;
protected Book() {
}
public Book(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getYear() {
return year;
}
public void setYear(int year) {
this.year = year;
}
}
Create the BookRepository interface
A repository is the layer that talks to the database on your behalf. With Spring Data JPA, you define an interface and Spring generates the implementation automatically.
- In the VS Code Explorer sidebar, right-click on the bookapi folder (src/main/java/com/example/bookapi) and select New Folder.
- Name the folder repository and press Enter.
- Right-click on the new repository folder and select New File.
- Name the file BookRepository.java and press Enter.
- Copy the following code into BookRepository.java:
package com.example.bookapi.repository;
import com.example.bookapi.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
// Spring Data JPA auto-generates CRUD methods from this interface
public interface BookRepository extends JpaRepository<Book, Long> {
}
How does Spring Data JPA generate code from an interface?
When your application starts, Spring detects any interface that extends JpaRepository. It generates a full implementation class behind the scenes with methods like findAll(), findById(), save(), and deleteById().
The type parameters <Book, Long> tell Spring which entity to manage and what type the primary key is. You write zero implementation code and get a fully working data access layer.
- Save BookRepository.java.
- Run your application by executing this command in the terminal:
./mvnw spring-boot:run
- Look at the terminal output. You should see Hibernate SQL output including a create table books statement. This confirms Spring scanned your entity and created the matching table in PostgreSQL.
Not seeing the create table statement?
- Make sure PostgreSQL is still running. You can check with sudo systemctl status postgresql.
- Verify that Book.java has the @Entity annotation on the class. Without it, Hibernate does not know to create a table.
- Check that your package name is com.example.bookapi.entity. Spring scans sub-packages of your main application package automatically.
Why isn't Hibernate creating my books table?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and the app started without errors.
ⓧ I'd like to double check the full code
package com.example.bookapi.repository;
import com.example.bookapi.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
Why a protected no-arg constructor?
JPA needs to create empty Book objects before populating them with data from the database. The protected visibility prevents other code from using the empty constructor directly while still letting Hibernate access it.
This pattern keeps your entity safe to use in application code while satisfying the framework's requirements.
Your data layer is complete. Spring knows how to map a Book object to a row in the books table, and your repository is ready to perform database operations. Next up, you will build the service and controller layers that turn these database operations into a working REST API.
Build the Service Layer and REST Controller
You have a database entity and a repository ready to store books. Now you need to build the layer that handles business logic and the layer that accepts HTTP requests from the outside world.
In Spring Boot, the service layer contains your application logic, while the controller layer maps incoming HTTP requests to service methods and returns responses. Together, they form the bridge between your API clients and the database.
In this step, get ready to:
- Create a BookService class with CRUD operations powered by BookRepository.
- Create a BookController with 5 REST endpoints mapped to /api/books.
- Test every endpoint using curl and verify data persists in PostgreSQL.
Create the service layer and its dependencies
The service class needs two supporting files before it can compile. It needs a DTO (Data Transfer Object) to define the shape of incoming request data, and a custom exception class for when a book is not found.
- In the VS Code Explorer sidebar, right-click the com.example.bookapi package folder and create a new folder called dto.
- Inside the dto folder, create a new file called BookRequest.java and paste the following code:
package com.example.bookapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
// A record that defines what data clients must send when creating or updating a book
public record BookRequest(
@NotBlank(message = "Title is required")
String title,
@NotBlank(message = "Author is required")
String author,
@Positive(message = "Year must be a positive number")
int year
) {
}
What does this code do?
A Java record is a compact way to define a class whose only job is to carry data. It automatically generates a constructor, getters, and equals/hashCode methods.
The validation annotations (@NotBlank and @Positive) tell Spring to reject requests that have empty titles, empty authors, or non-positive years. Spring enforces these rules automatically when the controller uses the @Valid annotation.
- Save BookRequest.java.
- Now create a new folder called exception inside the com.example.bookapi package.
- Inside the exception folder, create a new file called BookNotFoundException.java and paste the following code:
package com.example.bookapi.exception;
// Custom exception thrown when a requested book does not exist in the database
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(String message) {
super(message);
}
}
What does this code do?
This is a custom exception that the service throws when a book ID does not exist. Extending RuntimeException makes it an unchecked exception, so Spring can catch it at the controller level and return an appropriate HTTP error response.
- Save BookNotFoundException.java.
With the DTO and exception in place, you can now build the service itself.
- Create a new folder called service inside the com.example.bookapi package.
- Inside the service folder, create a new file called BookService.java and paste the following code:
package com.example.bookapi.service;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.entity.Book;
import com.example.bookapi.exception.BookNotFoundException;
import com.example.bookapi.repository.BookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BookService {
private final BookRepository bookRepository;
// Spring injects BookRepository automatically via constructor injection
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
// Return all books from the database
public List<Book> findAll() {
return bookRepository.findAll();
}
// Find a single book by ID, or throw if it does not exist
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book with id " + id + " not found"));
}
// Create a new book from the request data and save it
public Book create(BookRequest request) {
Book book = new Book(request.title(), request.author(), request.year());
return bookRepository.save(book);
}
// Update an existing book with new data from the request
public Book update(Long id, BookRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book with id " + id + " not found"));
book.setTitle(request.title());
book.setAuthor(request.author());
book.setYear(request.year());
return bookRepository.save(book);
}
// Delete a book by ID, or throw if it does not exist
public void delete(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException("Book with id " + id + " not found");
}
bookRepository.deleteById(id);
}
}
What does this code do?
The @Service annotation tells Spring to manage this class as a bean. Spring automatically injects BookRepository through the constructor. This pattern is called dependency injection.
Each method maps to a CRUD operation. The findById and update methods use orElseThrow to throw BookNotFoundException when the requested ID does not exist. The create method converts the incoming BookRequest DTO into a Book entity before saving.
- Save BookService.java.
Seeing red squiggles in VS Code?
If VS Code shows errors in your imports, make sure the dto and exception folders are inside the correct package path: src/main/java/com/example/bookapi/. The package declarations at the top of each file must match the folder structure exactly.
help me fix import errors in my Spring Boot service class
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
package com.example.bookapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public record BookRequest(
@NotBlank(message = "Title is required")
String title,
@NotBlank(message = "Author is required")
String author,
@Positive(message = "Year must be a positive number")
int year
) {
}
package com.example.bookapi.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(String message) {
super(message);
}
}
package com.example.bookapi.service;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.entity.Book;
import com.example.bookapi.exception.BookNotFoundException;
import com.example.bookapi.repository.BookRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> findAll() {
return bookRepository.findAll();
}
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book with id " + id + " not found"));
}
public Book create(BookRequest request) {
Book book = new Book(request.title(), request.author(), request.year());
return bookRepository.save(book);
}
public Book update(Long id, BookRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException("Book with id " + id + " not found"));
book.setTitle(request.title());
book.setAuthor(request.author());
book.setYear(request.year());
return bookRepository.save(book);
}
public void delete(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException("Book with id " + id + " not found");
}
bookRepository.deleteById(id);
}
}
Create the REST controller
The controller is the front door of your API. It receives HTTP requests, calls the appropriate service method, and returns the result as JSON.
- Create a new folder called controller inside the com.example.bookapi package.
- Inside the controller folder, create a new file called BookController.java and paste the following code:
package com.example.bookapi.controller;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.entity.Book;
import com.example.bookapi.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
// Marks this class as a REST controller that returns JSON responses
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
// Spring injects BookService automatically
public BookController(BookService bookService) {
this.bookService = bookService;
}
// GET /api/books - returns all books
@GetMapping
public List<Book> getAllBooks() {
return bookService.findAll();
}
// GET /api/books/{id} - returns a single book by ID
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return bookService.findById(id);
}
// POST /api/books - creates a new book, returns 201 Created
@PostMapping
public ResponseEntity<Book> createBook(@Valid @RequestBody BookRequest request) {
Book created = bookService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// PUT /api/books/{id} - updates an existing book
@PutMapping("/{id}")
public Book updateBook(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
return bookService.update(id, request);
}
// DELETE /api/books/{id} - deletes a book, returns 204 No Content
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteBook(@PathVariable Long id) {
bookService.delete(id);
}
}
What does this code do?
The @RestController annotation combines @Controller and @ResponseBody. This means every method automatically serializes its return value to JSON.
The @RequestMapping("/api/books") sets a base path for all endpoints in this controller. Each method annotation (@GetMapping, @PostMapping, @PutMapping, @DeleteMapping) maps to a specific HTTP method.
The @Valid annotation triggers validation of the incoming BookRequest body. The @PathVariable annotation extracts the {id} segment from the URL into the method parameter.
- Save BookController.java.
Seeing unresolved import errors?
Make sure the controller folder sits at src/main/java/com/example/bookapi/controller/. The package declaration must match the folder path. Also confirm that BookService.java and BookRequest.java are saved with the correct package names.
help me fix my BookController import errors
✔️ Awesome, I've got everything!
Great. Make sure BookController.java is saved before running the app.
ⓧ I'd like to double check the full code
package com.example.bookapi.controller;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.entity.Book;
import com.example.bookapi.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public List<Book> getAllBooks() {
return bookService.findAll();
}
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return bookService.findById(id);
}
@PostMapping
public ResponseEntity<Book> createBook(@Valid @RequestBody BookRequest request) {
Book created = bookService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public Book updateBook(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
return bookService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteBook(@PathVariable Long id) {
bookService.delete(id);
}
}
Test each endpoint using curl
Your API has five endpoints. You will test each one using curl from the terminal to confirm that data flows correctly from the HTTP request through the controller, service, repository, and into PostgreSQL.
- If your application is still running from the previous step, stop it with Ctrl+C in the terminal.
- Start the application fresh by running:
./mvnw spring-boot:run
- Wait for the log line that says Started BookapiApplication. This confirms the app is ready on port 8080.
- Open a second terminal window to run the curl commands (keep the first one running the app).
Start by creating a book. This tests the POST endpoint.
- Create a new book by running this command:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell", "year": 1949}'
- You should see a JSON response with the book data and an auto-generated id field:
{"id":1,"title":"1984","author":"George Orwell","year":1949}
- Retrieve all books by running:
curl http://localhost:8080/api/books
- You should see an array containing the book you just created.
- Retrieve a single book by its ID by running:
curl http://localhost:8080/api/books/1
- You should see the same book object returned as JSON.
- Update the book by running:
curl -X PUT http://localhost:8080/api/books/1 -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell", "year": 1948}'
- You should see the updated book with year changed to 1948.
- Delete the book by running:
curl -X DELETE http://localhost:8080/api/books/1 -v
- Look for 204 No Content in the verbose output. This confirms the book was deleted successfully.
- Verify the deletion by requesting all books again:
curl http://localhost:8080/api/books
- You should see an empty array [], confirming the book is gone.
Why does data persist across requests?
Every call to bookRepository.save() writes directly to your PostgreSQL database. The data survives until you restart the application (which drops and recreates the table because ddl-auto=create is set). This is fine for learning but would be replaced with migrations in production.
Getting Connection refused?
Make sure your first terminal still shows the application running. If it crashed, check the error output. A common cause is PostgreSQL not running. Verify it with sudo systemctl status postgresql.
If you see a 404 error, double check that your BookController has @RequestMapping("/api/books") and that the class is inside the com.example.bookapi.controller package. Spring's component scan only finds classes within the base package.
help me debug my Spring Boot API returning errors
Your REST API is fully operational. All five endpoints create, read, update, and delete books in PostgreSQL. Next up, you will add proper validation and error handling so your API rejects bad data and returns clean error responses.
Add Validation and Error Handling
Your API already validates incoming data with @NotBlank and @Positive annotations, and throws a custom exception when a book isn't found. But right now, Spring returns its default error response when something goes wrong.
Default error responses expose internal details like stack traces and Java class names. In this step, you'll create a global exception handler that intercepts errors and returns clean, consistent JSON responses.
In this step, get ready to:
- Create a global exception handler that catches validation and not-found errors.
- Test error handling by sending invalid data and requesting non-existent books.
Create the global exception handler
A global exception handler is a centralized class that catches exceptions thrown anywhere in your application and converts them into structured HTTP responses. Spring's @RestControllerAdvice annotation marks a class as this kind of handler.
- In VS Code, create a new file at src/main/java/com/example/bookapi/exception/GlobalExceptionHandler.java.
- This file goes in the same exception package as your existing BookNotFoundException.
- Paste the following code into the file:
package com.example.bookapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handles requests for books that don't exist
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Book Not Found");
return problemDetail;
}
// Handles validation failures from @Valid annotations
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problemDetail.setTitle("Validation Error");
// Collect each field's error message into a map
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
problemDetail.setProperty("fieldErrors", errors);
return problemDetail;
}
}
What does this code do?
This class has two handler methods, each catching a different type of exception:
- handleBookNotFound catches BookNotFoundException and returns a 404 response with the exception's message.
- handleValidationErrors catches MethodArgumentNotValidException (thrown when @Valid fails) and returns a 400 response with a map of which fields failed and why.
Both methods return a ProblemDetail object. This is Spring's built-in implementation of RFC 7807, which defines a standard JSON structure for API errors with fields like type, title, status, and detail.
- Save GlobalExceptionHandler.java.
- Spring Boot DevTools will automatically restart your application. Check your terminal to confirm the app restarts without errors.
App not restarting automatically?
If DevTools doesn't trigger a restart, stop the running app with Ctrl+C in your terminal and restart it manually:
./mvnw spring-boot:run
If you see a compilation error, check that the package declaration at the top of the file matches com.example.bookapi.exception.
Help me fix a compilation error in my GlobalExceptionHandler.
Test validation by sending invalid data
Your handler is in place. Now you'll send requests designed to fail and confirm the API returns clean JSON error responses instead of Spring's default output.
- Test validation by sending a POST request with empty fields:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"","author":"","year":0}'
You should see a JSON response with a 400 status, a title of Validation Error, and a fieldErrors object listing which fields failed. The title and author fields should show the messages you defined in BookRequest.
- Test the not-found handler by requesting a book ID that doesn't exist:
curl http://localhost:8080/api/books/999
You should see a JSON response with a 404 status, a title of Book Not Found, and a detail message saying the book was not found.
Why is this better than the default?
Without your handler, Spring returns a generic error body that can include Java class names and stack traces. Your handler returns a predictable JSON structure that frontend apps can parse and display to users.
Every error now follows the same shape: type, title, status, detail. This consistency makes it much easier for any client to handle errors gracefully.
Still seeing a generic error response?
Make sure your GlobalExceptionHandler class is in the com.example.bookapi.exception package. Spring's component scan picks up classes in this package automatically because it's a sub-package of your main application class.
Also confirm that the @RestControllerAdvice annotation is on the class (not just @ControllerAdvice).
Help me debug why my global exception handler isn't working.
Review the existing validation annotations
Your BookRequest record already has Jakarta Validation annotations that define the rules for incoming data. The @Valid annotation on your controller methods triggers these checks before your code runs.
How do validation and error handling work together?
When a request comes in, Spring checks the @NotBlank and @Positive annotations on BookRequest. If any rule fails, Spring throws a MethodArgumentNotValidException before your controller method even executes.
Your GlobalExceptionHandler catches that exception and returns a structured error listing exactly which fields failed and why. Without the handler, the learner would see Spring's default error page instead.
✔️ Awesome, I've got everything!
Great. Double check you've saved GlobalExceptionHandler.java and confirmed both error responses look correct.
ⓧ I'd like to double check the full code
Here is the complete GlobalExceptionHandler.java file:
package com.example.bookapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleBookNotFound(BookNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Book Not Found");
return problemDetail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problemDetail.setTitle("Validation Error");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
problemDetail.setProperty("fieldErrors", errors);
return problemDetail;
}
}
Secret mission
Add Search and Filtering
Your API returns every book in the database every time someone hits the GET endpoint. What if a user only wants books by a specific author or with a particular word in the title? In this secret mission, you will add search and filtering powered by Spring Data JPA query derivation.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL database and user (bookapi).
- The Spring Boot project folder (bookapi) on your Desktop.
- PostgreSQL service running in the background.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend your API soon.
- Your PostgreSQL service continues running in the background and starts automatically on boot.
- Your project files remain on your Desktop.
- Java 21, VS Code, and PostgreSQL remain installed on your machine.
Pause - I'll come back to this later
Stop the PostgreSQL service to free up system resources. Your database and project files stay intact for when you return.
- Stop the PostgreSQL service by running:
sudo systemctl stop postgresql
- Prevent PostgreSQL from starting automatically on boot by running:
sudo systemctl disable postgresql
When you're ready to return, re-enable and start the service with sudo systemctl enable postgresql && sudo systemctl start postgresql.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database, project files, and optionally the installed tools.
- Drop the project database and user by running:
sudo -u postgres dropdb bookapi
sudo -u postgres dropuser bookapi
- Delete the project folder from your Desktop by running:
rm -rf ~/Desktop/bookapi
- If you want to fully remove PostgreSQL from your system, stop the service and uninstall by running:
sudo systemctl stop postgresql
sudo apt-get --purge remove postgresql postgresql-*
- Remove residual PostgreSQL data and configuration files by running:
sudo rm -rf /var/lib/postgresql/
sudo rm -rf /etc/postgresql/
sudo rm -rf /var/log/postgresql/
Nice Work!
Nice Work!
You just built a production-style REST API from scratch with Spring Boot and PostgreSQL. Your API handles full CRUD operations, validates input, and returns clean error responses.
You've learned how to:
- Built a complete CRUD REST API with 5 endpoints connected to a real PostgreSQL database using Spring Boot.
- Structured your code into clean layers (entity, repository, service, controller, exception) and used Spring Data JPA to get full CRUD without writing SQL.
- Added input validation and a global exception handler that returns consistent JSON error responses using ProblemDetail.
- Secret Mission: Added search and filtering powered by Spring Data JPA query derivation.
Ready to quiz yourself?