Build a Book Collection REST API

Build a Spring Boot REST API with layered architecture and bean validation.

Introduction

30 Second Summary

Every time you browse an online bookstore, search for a title, or add a book to your wishlist, a REST API is handling that request behind the scenes. Building one yourself is one of the fastest ways to understand how backend systems actually work.

In this project, you will build a Book Collection REST API using Spring Boot. You'll create endpoints that let you add, retrieve, update, and delete books from a database.

What You'll Build

A fully functional REST API that manages a book collection, built with Spring Boot's layered architecture pattern and backed by an embedded H2 database.

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

  • A Spring Boot application with five REST endpoints for full CRUD operations (create, read, update, delete).
  • A properly layered architecture with controller, service (interface + implementation), and repository layers using constructor-based dependency injection.
  • Request validation using Jakarta bean validation with a global exception handler that returns structured JSON error responses.
  • Secret Mission: Add search and filtering by author or title using Spring Data JPA derived query methods.

Are there any prerequisites?

You should have basic Java knowledge (classes, interfaces, annotations) and an understanding of HTTP methods and JSON. No prior Spring Boot experience is needed.

Generate and Run Your Spring Boot Project

Every Spring Boot project starts the same way: you generate a pre-configured project skeleton from Spring Initializr. This tool wires together your build system, dependencies, and application structure so you can skip the boilerplate and start coding.

In this step, you will generate your project, open it in IntelliJ IDEA, and run it for the first time. By the end, you will have a running server ready for your API code.

In this step, get ready to:
  • Generate a Spring Boot project with the right dependencies.
  • Open and import the project in IntelliJ IDEA.
  • Run the application and confirm the server starts successfully.
Configure and generate your Spring Boot project

Spring Initializr is a web tool that generates a ready-to-run project with the exact dependencies you need. You will configure it to use Maven as the build system and add four starter dependencies.

  • Open your browser and navigate to https://start.spring.io.
  • Under **Project**, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 3.5.15.
  • In the Project Metadata section, set Group to com.example.
  • Set Artifact to bookcollection.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES (or press Ctrl+B) to open the dependency search.
  • Type Spring Web and select it from the list.
  • Type Spring Data JPA and select it.
  • Type H2 Database and select it.
  • Type Validation and select it.
  • Click GENERATE to download the project as a ZIP file.
  • Extract the downloaded bookcollection.zip file to your Desktop.

What is Spring Initializr doing for you?

Spring Initializr generates a complete Maven project structure with a pom.xml that includes all your selected dependencies. It also creates the main application class, a default application.properties file, and the Maven wrapper so you can build without installing Maven globally.

What do these four dependencies do?

  • Spring Web adds an embedded Tomcat server and the Spring MVC framework for handling HTTP requests.
  • Spring Data JPA provides the JPA layer that maps Java objects to database tables.
  • H2 Database is an embedded in-memory database. No installation required.
  • Validation enables Jakarta bean validation so you can enforce rules on incoming data.
Open the project in IntelliJ IDEA

Now that the project is extracted on your Desktop, you need to open it in IntelliJ IDEA so it can import the Maven dependencies and index your files.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open.
  • Navigate to your Desktop and select the bookcollection folder you extracted.
  • Click OK to open the project.

IntelliJ will detect the pom.xml and begin importing the Maven project. You will see a progress bar in the bottom-right corner as it downloads dependencies.

Why is IntelliJ downloading files?

Maven downloads the JAR files for each dependency you selected in Spring Initializr. This only happens the first time you open the project. IntelliJ caches these files locally so future builds are fast.

Import seems stuck or failing?

Make sure you have an internet connection. Maven needs to download dependencies from Maven Central the first time. If you see red underlines in files, wait for the import progress bar to finish before troubleshooting further.

If IntelliJ does not start importing automatically, right-click the pom.xml file in the Project panel and select Maven then Reload Project.

Help me fix Maven import issues in IntelliJ

Run the application

With the project imported, you can start the application using the Maven wrapper that Spring Initializr included. The Maven wrapper means you do not need Maven installed globally.

  • Open the terminal in IntelliJ by pressing Option+F12 (macOS) or Alt+F12 (Windows).
  • Start the application by running this command:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run

What does this command do?

The mvnw file is the Maven wrapper. It downloads the correct Maven version for your project and runs the spring-boot:run goal. This compiles your code and starts the embedded Tomcat server.

Wait for the startup to complete. You should see a line in the console that includes Started BookcollectionApplication. This confirms the server is running.

  • Open your browser and visit http://localhost:8080.

You will see a Whitelabel Error Page. This is expected. It means the server is running but has no endpoints defined yet.

  • Return to IntelliJ's terminal and press Ctrl+C to stop the application.

Don't see Started BookcollectionApplication?

Check for error messages above the failure point. The most common issue is a port conflict. If you see a message about port 8080 being in use, close any other application using that port and try again.

If you see JAVA_HOME errors, confirm your JDK 17 is properly configured. Run java -version in the terminal to check.

Help me troubleshoot my Spring Boot application startup

✔️ Awesome, I've got everything!

Great. Double check that you have stopped the application with Ctrl+C before moving on.

ⓧ I'd like to double check the full code

Your main application class should look like this:

package com.example.bookcollection;

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

@SpringBootApplication
public class BookcollectionApplication {

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

Your application.properties file should contain:

spring.application.name=bookcollection

If anything looks different, re-check your Spring Initializr settings (group com.example, artifact bookcollection) and regenerate the project.

Your Spring Boot project is generated, imported, and running. Next up, you will create the Book entity and repository to give your API something to store.

Create the Book Entity and Repository

Your Spring Boot server is running. Now you need to tell it what data to store and how to store it.

Every REST API needs a data model. In Spring Boot, you define your data model as a JPA entity class. JPA maps your Java class to a database table automatically. You also need a repository interface that gives you database operations (create, read, update, delete) without writing any SQL.

In this step, get ready to:
  • Create a Book entity with JPA annotations that maps to a database table.
  • Create a BookRepository interface that provides free CRUD operations.
  • Configure the H2 in-memory database and verify the table is auto-created.
Create the Book entity class

A JPA entity is a plain Java class annotated so that the framework knows how to persist it to a database. Each field becomes a column in the table.

  • In IntelliJ's Project tool window on the left, expand src/main/java.
  • Right-click the com.example.bookcollection package and select New | Package.
  • Type model and press Enter.
  • Right-click the new model package and select New | Java Class.
  • Type Book and press Enter.
  • Replace the entire contents of the new file with the following code:
package com.example.bookcollection.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Entity
public class Book {

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

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

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

    @NotBlank(message = "ISBN is required")
    @Column(unique = true)
    private String isbn;

    @Min(value = 1450, message = "Published year must be at least 1450")
    private int publishedYear;

    // Required by JPA - it needs a no-args constructor to create instances
    protected Book() {
    }

    // Public constructor for creating Book objects in your code
    public Book(String title, String author, String isbn, int publishedYear) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
        this.publishedYear = publishedYear;
    }

    public Long getId() {
        return id;
    }

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

    public String getTitle() {
        return title;
    }

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

    public String getAuthor() {
        return author;
    }

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

    public String getIsbn() {
        return isbn;
    }

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

    public int getPublishedYear() {
        return publishedYear;
    }

    public void setPublishedYear(int publishedYear) {
        this.publishedYear = publishedYear;
    }
}

What does this code do?

This class defines your data model. Each annotation tells Spring and JPA how to handle the class:

  • @Entity marks this class as a database table. JPA will create a BOOK table automatically.
  • @Id and @GeneratedValue make the id field an auto-incrementing primary key.
  • @NotBlank and @Min are validation constraints that reject invalid data before it reaches the database.
  • The protected no-args constructor is required by JPA internally. The public constructor is what you use to create Book objects in your own code.

Why jakarta instead of javax?

Spring Boot 3.x uses the jakarta namespace for persistence and validation annotations. Older tutorials may reference javax.persistence, but that package was renamed when Java EE moved to the Eclipse Foundation. If you see javax imports in other guides, know that jakarta is the modern equivalent.

  • Save Book.java with Cmd+S (macOS) or Ctrl+S (Windows).
  • Check that the file appears under model in the Project tool window and that there are no red underlines in the editor.

Seeing red underlines on the imports?

  • Make sure IntelliJ finished downloading Maven dependencies. Look for a progress bar at the bottom of the window. If imports are unresolved, click the Maven reload icon (circular arrows) in the Maven tool window on the right.
  • Confirm you picked the correct Spring Boot version (3.5.15) and included the Validation dependency on start.spring.io. Without it, the jakarta.validation imports will not resolve.

Help me fix unresolved imports in my Book entity class.

✔️ Awesome, I've got everything!

Great. Double check you have saved Book.java before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcollection.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Entity
public class Book {

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

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

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

    @NotBlank(message = "ISBN is required")
    @Column(unique = true)
    private String isbn;

    @Min(value = 1450, message = "Published year must be at least 1450")
    private int publishedYear;

    protected Book() {
    }

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

    public Long getId() {
        return id;
    }

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

    public String getTitle() {
        return title;
    }

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

    public String getAuthor() {
        return author;
    }

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

    public String getIsbn() {
        return isbn;
    }

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

    public int getPublishedYear() {
        return publishedYear;
    }

    public void setPublishedYear(int publishedYear) {
        this.publishedYear = publishedYear;
    }
}
Create the BookRepository interface

Spring Data JPA eliminates boilerplate database code. By extending a single interface, you get findAll, findById, save, deleteById, and existsById for free.

  • Right-click the com.example.bookcollection package in the Project tool window and select New | Package.
  • Type repository and press Enter.
  • Right-click the new repository package and select New | Java Class.
  • In the dialog, select Interface from the kind dropdown, type BookRepository, and press Enter.
  • Replace the entire contents of the new file with the following code:
package com.example.bookcollection.repository;

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

// Extending JpaRepository gives you CRUD methods with zero implementation code
public interface BookRepository extends JpaRepository<Book, Long> {
}

What does this code do?

JpaRepository<Book, Long> tells Spring Data that this repository manages Book entities and that the primary key type is Long. Spring automatically generates the implementation at runtime, so you never write a single line of SQL for basic CRUD.

  • Save BookRepository.java.
  • Confirm that BookRepository.java appears under the repository package in the Project tool window with no red underlines.

Red underline on JpaRepository?

This means the spring-boot-starter-data-jpa dependency is missing. Open pom.xml and confirm you see a spring-boot-starter-data-jpa entry under <dependencies>. If not, re-download your project from start.spring.io with the Spring Data JPA dependency checked.

Help me fix unresolved JpaRepository import.

✔️ Awesome, I've got everything!

Great. Double check you have saved BookRepository.java before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcollection.repository;

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

public interface BookRepository extends JpaRepository<Book, Long> {
}
Configure the H2 database

H2 is an embedded in-memory database. It runs inside your Spring Boot application with zero setup. You just need to tell Spring where to find it and how to manage the schema.

  • In the Project tool window, expand src/main/resources and double-click application.properties to open it.
  • Replace the entire contents of the file with the following configuration:
spring.application.name=bookcollection

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

# Configure the in-memory database connection
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Auto-create tables from entity classes, drop on shutdown
spring.jpa.hibernate.ddl-auto=create-drop

# Log SQL statements to the console for debugging
spring.jpa.show-sql=true

What do these properties do?

  • spring.h2.console.enabled=true activates a web-based database viewer at /h2-console.
  • spring.datasource.url=jdbc:h2:mem:bookdb creates a named in-memory database called bookdb. Data lives only while the app is running.
  • spring.jpa.hibernate.ddl-auto=create-drop tells Hibernate to create tables on startup and drop them on shutdown. Perfect for development.
  • spring.jpa.show-sql=true prints every SQL statement to your console so you can see what JPA does behind the scenes.
  • Save application.properties.
  • Restart the application by running this in your terminal:
.\mvnw.cmd spring-boot:run
  • In the console output, look for the line Started BookcollectionApplication. You should also see SQL statements like create table book printed by Hibernate.
  • Open your browser and navigate to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:bookdb.
  • Leave User Name as sa and Password empty.
  • Click Connect.
  • In the left panel, you should see the BOOK table listed with columns: ID, TITLE, AUTHOR, ISBN, and PUBLISHED_YEAR.

Don't see the BOOK table in H2 console?

  • Make sure the JDBC URL field says exactly jdbc:h2:mem:bookdb. The default value shown in the console form is different and connects to a separate empty database.
  • Confirm the application is still running in your terminal. If it stopped, the in-memory database was destroyed.
  • Check that Book.java has the @Entity annotation at the class level. Without it, JPA ignores the class entirely.

Help me connect to the H2 console and find my BOOK table.

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties and that the application is running.

ⓧ I'd like to double check the full code

spring.application.name=bookcollection
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

Your data layer is locked in. The Book entity defines what to store, and BookRepository handles how to store it. Next up, you will build the service layer that puts business logic between your controller and the database.

Build the Service Layer

Your Book entity and repository are in place. The repository gives you database access, but enterprise applications never let controllers talk directly to the repository.

Instead, a service layer sits between them. The service holds your business logic and keeps your controller thin. In this step, you will create an interface that defines your service contract and then build the implementation class that fulfils it.

In this step, get ready to:
  • Create the BookService interface that declares your CRUD methods.
  • Implement the service with BookServiceImpl using constructor-based dependency injection.
Create the BookService interface

An interface defines the contract for your service. It lists what methods are available without specifying how they work. This lets Spring inject different implementations (including mocks for testing) without changing the code that depends on the service.

  • In the Project tool window, right-click the com.example.bookcollection package.
  • Select New > Package.
  • Type service and press Enter.
  • Right-click the new service package and select New > Java Class.
  • In the popup, select Interface as the kind.
  • Name it BookService and press Enter.
  • Replace the generated contents with the following code:
package com.example.bookcollection.service;

import com.example.bookcollection.model.Book;

import java.util.List;
import java.util.Optional;

public interface BookService {

    List<Book> getAllBooks();

    Optional<Book> getBookById(Long id);

    Book createBook(Book book);

    Book updateBook(Long id, Book book);

    void deleteBook(Long id);
}

What does this interface define?

  • getAllBooks() returns every book in the database as a list.
  • getBookById() returns an Optional because the book might not exist. This avoids returning null.
  • createBook() and updateBook() return the saved Book so the caller gets the auto-generated ID.
  • deleteBook() returns void because there is nothing meaningful to return after deletion.
  • Save BookService.java with Ctrl+S (Windows) or Cmd+S (macOS).

Why use an interface instead of a class?

Enterprise Java teams almost always define a service interface separate from its implementation. This makes the dependency explicit. Your controller depends on the interface, not the implementation.

When you write tests later, you can swap in a mock implementation without touching the controller. Spring's proxy-based dependency injection model relies on this pattern.

Implement the service with constructor injection

The implementation class does the actual work. It uses the BookRepository you created in the previous step to interact with the database. Spring will automatically inject the repository through the constructor.

  • Right-click the service package and select New > Java Class.
  • Name it BookServiceImpl and press Enter.
  • Replace the generated contents with the following code:
package com.example.bookcollection.service;

import com.example.bookcollection.model.Book;
import com.example.bookcollection.repository.BookRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class BookServiceImpl implements BookService {

    private final BookRepository bookRepository;

    // Spring injects BookRepository automatically via this constructor
    public BookServiceImpl(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    @Override
    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }

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

    @Override
    public Book updateBook(Long id, Book book) {
        return bookRepository.findById(id)
                .map(existingBook -> {
                    existingBook.setTitle(book.getTitle());
                    existingBook.setAuthor(book.getAuthor());
                    existingBook.setIsbn(book.getIsbn());
                    existingBook.setPublishedYear(book.getPublishedYear());
                    return bookRepository.save(existingBook);
                })
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }

    @Override
    public void deleteBook(Long id) {
        if (!bookRepository.existsById(id)) {
            throw new RuntimeException("Book not found with id: " + id);
        }
        bookRepository.deleteById(id);
    }
}

What does this code do?

  • @Service tells Spring to register this class as a bean in the application context. Spring will find and inject it wherever a BookService is needed.
  • The constructor takes BookRepository as a parameter. Because there is only one constructor, Spring uses it for injection automatically. No @Autowired annotation needed.
  • private final makes the repository reference immutable after construction. This ensures the dependency cannot be swapped at runtime.
  • updateBook() fetches the existing book, copies the new field values onto it, then saves. If the book does not exist, it throws a RuntimeException.
  • deleteBook() checks existence first so it can throw a meaningful error if the ID is invalid.
  • Save BookServiceImpl.java.
  • Restart the application by running this command in the IntelliJ terminal:
.\mvnw.cmd spring-boot:run
  • Look at the console output. You should see Started BookcollectionApplication with no errors about unsatisfied dependencies.

Seeing an unsatisfied dependency error?

  • Check that BookServiceImpl has the @Service annotation. Without it, Spring cannot find the bean.
  • Make sure the constructor parameter type is BookRepository, not BookService. The service depends on the repository, not itself.
  • Confirm both files are in the com.example.bookcollection.service package. A typo in the package name will put them outside Spring's component scan.

Still stuck? Help me debug an unsatisfied dependency error in my Spring Boot service layer.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete BookService.java interface:

package com.example.bookcollection.service;

import com.example.bookcollection.model.Book;

import java.util.List;
import java.util.Optional;

public interface BookService {

    List<Book> getAllBooks();

    Optional<Book> getBookById(Long id);

    Book createBook(Book book);

    Book updateBook(Long id, Book book);

    void deleteBook(Long id);
}

And the complete BookServiceImpl.java:

package com.example.bookcollection.service;

import com.example.bookcollection.model.Book;
import com.example.bookcollection.repository.BookRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class BookServiceImpl implements BookService {

    private final BookRepository bookRepository;

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

    @Override
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    @Override
    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }

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

    @Override
    public Book updateBook(Long id, Book book) {
        return bookRepository.findById(id)
                .map(existingBook -> {
                    existingBook.setTitle(book.getTitle());
                    existingBook.setAuthor(book.getAuthor());
                    existingBook.setIsbn(book.getIsbn());
                    existingBook.setPublishedYear(book.getPublishedYear());
                    return bookRepository.save(existingBook);
                })
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }

    @Override
    public void deleteBook(Long id) {
        if (!bookRepository.existsById(id)) {
            throw new RuntimeException("Book not found with id: " + id);
        }
        bookRepository.deleteById(id);
    }
}

Your service layer is wired up and Spring can inject it anywhere it is needed. Next, you will create the REST controller that exposes your book operations as HTTP endpoints.

Create the REST Controller

Your service layer is ready to handle all the business logic for managing books. Now you need a way for the outside world to actually reach that logic.

A REST controller is the entry point for HTTP requests. It maps URLs and HTTP methods (GET, POST, PUT, DELETE) to your service methods, translating JSON request bodies into Java objects and returning JSON responses.

In this step, get ready to:
  • Create the BookController class with constructor injection.
  • Implement five REST endpoints for full CRUD operations.
  • Test all endpoints using curl or PowerShell.
Create the BookController class

The controller class sits in its own package and uses the same constructor injection pattern you applied in the service layer. Spring sees the @RestController annotation and automatically registers this class to handle incoming HTTP requests.

  • In IntelliJ's Project panel on the left, right-click the com.example.bookcollection package inside src/main/java.
  • Select New then Package.
  • Enter controller as the package name and press Enter.
  • Right-click the new controller package, select New then Java Class.
  • Name it BookController and press Enter.
  • Replace the entire file contents with this code:
package com.example.bookcollection.controller;

import com.example.bookcollection.model.Book;
import com.example.bookcollection.service.BookService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

// Marks this class as a REST controller that returns JSON responses
@RestController
// All endpoints in this controller start with /api/books
@RequestMapping("/api/books")
public class BookController {

    // Inject the BookService interface (Spring picks BookServiceImpl at runtime)
    private final BookService bookService;

    // Constructor injection makes the dependency explicit and testable
    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // GET /api/books - returns all books as a JSON array
    @GetMapping
    public List<Book> getAllBooks() {
        return bookService.getAllBooks();
    }
}

What does this code do?

@RestController tells Spring this class handles HTTP requests and automatically serializes return values to JSON. @RequestMapping("/api/books") sets the base URL path for all endpoints in this controller.

The constructor receives a BookService (the interface, not the implementation). Spring automatically injects BookServiceImpl at runtime because it is the only class implementing that interface.

  • Save BookController.java.
  • Check that IntelliJ shows no red underlines in the file.

Seeing red underlines on the imports?

Make sure you created the file inside the controller package under com.example.bookcollection. If IntelliJ still shows errors, wait a few seconds for the Maven project to re-index.

help me fix red underlines in my BookController imports

Implement the remaining four endpoints

The getAllBooks method handles the simplest case. The remaining endpoints use ResponseEntity to control HTTP status codes and handle cases like "book not found."

  • In BookController.java, add the GET-by-ID endpoint below the getAllBooks method by pasting this code:
    // GET /api/books/{id} - returns a single book or 404 if not found
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

What does this code do?

@PathVariable extracts the {id} value from the URL. The service returns an Optional<Book>, so .map(ResponseEntity::ok) wraps a found book in a 200 response.

If the Optional is empty, .orElse(ResponseEntity.notFound().build()) returns a 404 status instead.

  • Below the getBookById method, add the three write endpoints by pasting this code:
    // POST /api/books - creates a new book, returns 201 Created
    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        Book created = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // PUT /api/books/{id} - updates an existing book
    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
        Book updated = bookService.updateBook(id, book);
        return ResponseEntity.ok(updated);
    }

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

What does this code do?

@RequestBody tells Spring to deserialize the incoming JSON into a Book object. @Valid triggers bean validation on that object before the method runs.

Each method returns a ResponseEntity with an appropriate HTTP status code. POST returns 201 (Created), PUT returns 200 (OK), and DELETE returns 204 (No Content) with an empty body.

  • Update the import section at the top of BookController.java to include all required classes. Replace the existing imports with:
import com.example.bookcollection.model.Book;
import com.example.bookcollection.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

Let IntelliJ handle imports

If you prefer, press Alt+Enter (Windows) or Option+Enter (macOS) on any red-underlined class name and select Import class. IntelliJ will add the correct import automatically.

  • Save BookController.java.
  • Verify IntelliJ shows no red underlines anywhere in the file.

Cannot resolve symbol ResponseEntity?

Double-check that the import org.springframework.http.ResponseEntity is present at the top of the file. If IntelliJ still cannot resolve it, try clicking File then Invalidate Caches / Restart to force a re-index.

help me resolve missing imports in my Spring Boot controller

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.bookcollection.controller;

import com.example.bookcollection.model.Book;
import com.example.bookcollection.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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.getAllBooks();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        Book created = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
        Book updated = bookService.updateBook(id, book);
        return ResponseEntity.ok(updated);
    }

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

Your controller is wired up. Now you will send real HTTP requests to confirm each endpoint returns the correct status code and data.

  • Start the application by running this in your IntelliJ terminal:
.\mvnw.cmd spring-boot:run

Using macOS or Linux?

Replace .\mvnw.cmd with ./mvnw in all terminal commands throughout this project.

  • Wait until you see Started BookcollectionApplication in the console output.
  • Open a second terminal tab in IntelliJ by clicking the + icon in the terminal panel.

Windows PowerShell and curl

In Windows PowerShell, curl is an alias for a different command. Use curl.exe instead to call the real curl program. All commands below use curl.exe so they work in both PowerShell and Git Bash.

  • Create a book by running this POST request:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Hobbit\",\"author\":\"J.R.R. Tolkien\",\"isbn\":\"978-0547928227\",\"publishedYear\":1937}"

You should see a JSON response with the book data and an auto-generated id field.

  • Retrieve all books by running:
curl.exe http://localhost:8080/api/books

You should see a JSON array containing the book you just created.

  • Retrieve the single book by ID:
curl.exe http://localhost:8080/api/books/1

You should see the same book object returned directly (not in an array).

  • Update the book's published year by running this PUT request:
curl.exe -X PUT http://localhost:8080/api/books/1 -H "Content-Type: application/json" -d "{\"title\":\"The Hobbit\",\"author\":\"J.R.R. Tolkien\",\"isbn\":\"978-0547928227\",\"publishedYear\":1937}"

You should see the updated book object in the response.

  • Delete the book by running:
curl.exe -X DELETE http://localhost:8080/api/books/1 -v

Look for 204 No Content in the verbose output. This confirms the book was deleted and no body is returned.

  • Confirm that requesting the deleted book returns a 404 by running:
curl.exe http://localhost:8080/api/books/1 -v

Look for 404 Not Found in the verbose output. Your API correctly reports that the book no longer exists.

Getting connection refused?

Make sure the application is still running in your first terminal tab. You should see log output from the running process. If you accidentally closed it, run .\mvnw.cmd spring-boot:run again.

If you see a JSON error instead of 404, check that your getBookById method uses ResponseEntity.notFound().build() in the .orElse() call.

help me debug my REST controller responses

  • Stop the application by pressing Ctrl+C in the terminal tab where it is running.

Your REST API now handles all five CRUD operations with proper status codes. Next up, you will add request validation so the API rejects invalid data before it reaches the database.

Add Validation and Error Handling

Your API endpoints work, but what happens when someone sends bad data? Right now, if a client posts a book with no title or a nonsense year, Spring returns a verbose default error response that exposes internal details.

In this step, you will build a global exception handler that intercepts validation failures and returns clean, structured JSON error messages. This is the pattern enterprise APIs use to communicate problems back to clients.

In this step, get ready to:
  • Confirm your validation annotations and @Valid are wired up correctly.
  • Create a global exception handler that returns field-specific error messages.
  • Test validation by sending invalid requests and verifying structured error responses.
Review your validation setup

Your Book entity already has Jakarta validation annotations from when you created it. These annotations define the rules each field must follow.

  • In IntelliJ, open Book.java in the model package.
  • Confirm you see @NotBlank on title, author, and isbn, and @Min(value = 1450) on publishedYear.
  • Open BookController.java and confirm that both createBook and updateBook methods have @Valid before @RequestBody.

How do @Valid and the annotations work together?

The annotations on Book.java (@NotBlank, @Min) define the rules. The @Valid annotation on the controller parameter tells Spring to actually enforce those rules before the method runs.

Without @Valid, the annotations are just metadata. With it, Spring intercepts the request, checks every annotated field, and throws a MethodArgumentNotValidException if anything fails.

The validation rules are in place, but Spring's default error response is a large JSON blob with internal class names and stack trace details. Your API clients need a simple map of which fields failed and why.

Create the global exception handler

A @RestControllerAdvice class acts as a centralized error handler for your entire API. Instead of handling exceptions in each controller method, this single class catches them all and formats consistent responses.

  • In the IntelliJ Project panel on the left, right-click the com.example.bookcollection package.
  • Select New, then Package.
  • Enter com.example.bookcollection.exception and press Enter.
  • Right-click the new exception package, select New, then Java Class.
  • Name it GlobalExceptionHandler and press Enter.
  • Replace the contents of the file with the following code:
package com.example.bookcollection.exception;

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

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle validation errors from @Valid annotations
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }

    // Handle not-found errors from the service layer
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

What does this code do?

The @RestControllerAdvice annotation tells Spring this class handles exceptions across all controllers. You do not need to register it anywhere. Spring detects it automatically through component scanning.

The handleValidationExceptions method catches MethodArgumentNotValidException (thrown when @Valid fails). It extracts each field error into a simple map like {"title": "Title is required"} and returns it with a 400 status.

The handleRuntimeException method catches the RuntimeException your service throws when a book is not found. It returns the message with a 404 status.

  • Save GlobalExceptionHandler.java.
  • Restart your application by pressing Ctrl+C in the terminal, then running .\.mvnw.cmd spring-boot:run again.
  • The application should start without errors. You should see Started BookcollectionApplication in the console.

Seeing a compilation error?

Make sure the package declaration at the top of the file matches package com.example.bookcollection.exception; exactly. Also check that you have the correct imports for MethodArgumentNotValidException (from org.springframework.web.bind) and RestControllerAdvice (from org.springframework.web.bind.annotation).

Help me fix my GlobalExceptionHandler compilation error

Test validation with invalid requests

Now you will send requests with invalid data to confirm your exception handler returns clean, structured errors.

  • Open a new terminal tab in IntelliJ (click the + icon in the Terminal panel).
  • Send a POST request with an empty title and a publishedYear of 0 by running this command:
curl.exe -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d "{\"title\":\"\",\"author\":\"\",\"isbn\":\"\",\"publishedYear\":0}"

What should I see?

You should see a JSON response with HTTP 400 status containing field-specific error messages:

{
  "title": "Title is required",
  "author": "Author is required",
  "isbn": "ISBN is required",
  "publishedYear": "Published year must be at least 1450"
}
  • Now test the not-found handler by requesting a book ID that does not exist:
curl.exe http://localhost:8080/api/books/999

What should I see?

The GET by ID endpoint returns a plain 404 (because getBookById uses ResponseEntity.notFound().build() directly). To see the RuntimeException handler in action, try updating a non-existent book:

curl.exe -X PUT http://localhost:8080/api/books/999 -H "Content-Type: application/json" -d "{\"title\":\"Test\",\"author\":\"Author\",\"isbn\":\"123\",\"publishedYear\":2000}"

You should see a structured 404 response:

{
  "error": "Book not found with id: 999"
}
  • Verify the validation POST response contains all four field error messages.
  • Verify the PUT to a non-existent ID returns the structured error JSON.

Getting a 500 error instead of 400?

Make sure @Valid appears before @RequestBody in your controller's createBook and updateBook methods. Without @Valid, Spring skips validation entirely and the request passes through to the service layer.

Help me debug my validation error responses

✔️ Awesome, I've got everything!

Great. Double check you have saved GlobalExceptionHandler.java and your application restarts cleanly.

ⓧ I'd like to double check the full code

Here is the complete GlobalExceptionHandler.java file:

package com.example.bookcollection.exception;

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

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

And here is your BookController.java (confirm @Valid is present on both createBook and updateBook):

package com.example.bookcollection.controller;

import com.example.bookcollection.model.Book;
import com.example.bookcollection.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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.getAllBooks();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
        Book created = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
        Book updated = bookService.updateBook(id, book);
        return ResponseEntity.ok(updated);
    }

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

Your API now validates every incoming request and returns clear, actionable error messages. Clients know exactly which fields need fixing and what the rules are. Next up, you will tackle the Secret Mission and add search and filtering to your API.

Secret mission

Add Search and Filtering

Your API can create, read, update, and delete books. But what if your collection grows to hundreds of entries? Users need a way to search. In this mission, you will add search-by-author and search-by-title endpoints using Spring Data JPA derived query methods.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with an in-memory database, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting, or delete them entirely.

Resources you used:

  • The bookcollection project folder on your Desktop.
  • H2 in-memory database (automatically destroyed when the application stops).

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with additional endpoints and features.

  • Your project files stay in the bookcollection folder on your Desktop.
  • The H2 database only exists while the application is running. Each time you start the app, it creates a fresh database.
  • Nothing is consuming resources while the application is stopped.

Pause - I'll come back to this later

Since this project is entirely local and uses an in-memory database, there is nothing to pause. The application only uses system resources while actively running.

  • If the Spring Boot application is still running in your terminal, press Ctrl+C to stop it.
  • Your project files remain on your Desktop for when you return.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • If the Spring Boot application is still running in your terminal, press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/bookcollection

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\bookcollection
  • The H2 database was in-memory only and is already gone once the application stopped. No additional cleanup is needed.

Nice Work!

Nice Work!

You just built a complete Book Collection REST API using Spring Boot. This is the exact architecture pattern that enterprise Java teams use in production every day.

You've learned how to:

  • Build a REST API with full CRUD endpoints using Spring Boot, returning proper HTTP status codes (200, 201, 204, 400, 404).
  • Implement a layered architecture with a JPA entity, interface-based service layer with constructor injection, and a Spring Data JPA repository backed by an embedded H2 database.
  • Validate incoming requests using Jakarta bean validation annotations and return structured JSON error responses through a global exception handler.
  • Secret Mission: Added search and filtering using Spring Data JPA derived query methods to return case-insensitive partial matches by author or title.

Ready to quiz yourself?