Build a Book Catalog REST API

Build a Spring Boot REST API with JPA, DTOs, and Mockito unit tests.

Introduction

30 Second Summary

Almost every backend interview includes a question about REST APIs, layered architecture, or how to test service logic without a database. These are the patterns that separate junior developers from mid-level engineers.

In this project, you will build a Book Catalog REST API from scratch using Spring Boot 3.5. You will progressively layer in a database, service logic, DTOs, and unit tests until you have a professional-grade project structure.

What You'll Build

You will have a running API on your machine where you can fire off curl commands to create, retrieve, and delete books, then prove it all works by running automated tests that pass in seconds.

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

  • A live REST API on localhost:8080 where you POST a new book and immediately GET it back as structured JSON.
  • An H2 in-memory database managed entirely by JPA/Hibernate. You will never write a single line of SQL.
  • A suite of unit tests using Mockito that prove your service logic works correctly, even without a real database running.
  • Secret Mission: Add global exception handling so your API returns clean, predictable error messages with proper HTTP status codes instead of stack traces.

Are there any prerequisites?

You should have basic Java familiarity (classes, interfaces, annotations). Git, Maven, and a Java 17 JDK should be installed on your machine before you begin.

Before We Start

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

Every backend Java interview circles back to the same patterns: layered architecture, dependency injection, REST design, and testing with mocks. This project gives you a working codebase that demonstrates all of them.

Set Up the Project

You need a working Spring Boot skeleton before you can build anything on top of it. Spring Initializr generates a pre-configured Maven project so you skip manual dependency wiring entirely.

In this step, you will install IntelliJ IDEA, generate your project from Spring Initializr, and confirm the app starts on port 8080.

In this step, get ready to:
  • Install IntelliJ IDEA as your Java IDE.
  • Generate a Spring Boot project with the right dependencies.
  • Run the app and confirm it starts successfully.
Install IntelliJ IDEA

IntelliJ IDEA is a Java IDE with built-in support for Spring Boot projects. The free core features are all you need for this project.

macOS

  • Open the downloaded .dmg file.
  • Drag the IntelliJ IDEA icon into the Applications folder.
  • Press Cmd+Space to open Spotlight.
  • Type IntelliJ IDEA and press Enter to launch it.

Windows

  • Run the downloaded .exe installer.
  • Follow the wizard steps, accepting the defaults.
  • Click Finish when the installation completes.
  • Press the Windows key to open your search bar.
  • Type IntelliJ IDEA and press Enter to launch it.

What about the Ultimate trial?

On first launch, IntelliJ IDEA offers a free 30-day Ultimate trial. You do not need Ultimate for this project. Skip the trial and continue with the free core features.

You should see the IntelliJ IDEA Welcome screen with options to create or open a project.

IntelliJ IDEA not launching?

On macOS, you may see a security prompt saying the app is from an identified developer. Click Open to allow it. On Windows, if the installer asks for administrator permissions, click Yes to proceed.

Help me troubleshoot my IntelliJ IDEA installation.

Generate the Spring Boot project

Spring Initializr is a web tool that generates a ready-to-run Maven project with the dependencies you choose. It saves you from manually wiring pom.xml dependencies by hand.

  • Open https://start.spring.io in your browser.
  • Select Maven as the project type.
  • Select Java as the language.
  • Set the Spring Boot version to 3.5.16.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to bookcatalog.
  • Set Java version to 17.
  • Click Add Dependencies and add the following four dependencies:
  • Spring Web (provides REST controller support and embedded Tomcat).
  • Spring Data JPA (provides JPA with Hibernate for database access).
  • H2 Database (an in-memory database that requires zero setup).
  • Validation (for request body validation annotations).
  • Click Generate to download the .zip file.
  • Unzip the downloaded bookcatalog.zip to your Desktop.

Now open the project in IntelliJ IDEA.

  • In the IntelliJ Welcome screen, click Open.
  • Navigate to your Desktop and select the bookcatalog folder.
  • Click OK (or Open on macOS) to load the project.

What happens when IntelliJ opens the project?

IntelliJ detects the pom.xml and imports the project as a Maven project. It downloads all dependencies in the background. Wait for the progress bar at the bottom to finish before proceeding.

In the Project panel on the left, you should see the src/main/java folder structure with BookcatalogApplication.java inside com.example.bookcatalog.

Don't see any files in the Project panel?

Make sure you selected the bookcatalog folder itself (the one containing pom.xml), not a parent folder. If IntelliJ still shows nothing, click File then Invalidate Caches / Restart and reopen the project.

Help me troubleshoot opening my Spring Boot project in IntelliJ.

Run the app to verify setup

The generated project includes a main class that boots up an embedded Tomcat server. Running it proves your dependencies, JDK, and project structure are all wired correctly.

  • Open a terminal inside IntelliJ by clicking View in the top menu bar, then Tool Windows, then Terminal.

Before you run the next command, what do you expect to see if your project is set up correctly?

  • Start the application by running this command:

macOS

./mvnw spring-boot:run

Windows

mvnw.cmd spring-boot:run

The first run downloads all Maven dependencies, so it may take a minute or two. When it finishes, you should see output ending with lines like:

Started BookcatalogApplication in X.XXX seconds
Tomcat started on port 8080 (http)

What do these messages mean?

"Started BookcatalogApplication" confirms Spring Boot initialised your app context successfully. "Tomcat started on port 8080" means the embedded web server is running and listening for HTTP requests.

Your app is live at http://localhost:8080, even though it has no endpoints yet.

  • Stop the server by pressing Ctrl+C in the terminal.

Seeing 'JAVA_HOME not set' or build errors?

The Maven wrapper needs Java 17. Confirm your Java version by running java -version in the same terminal. If it shows a version below 17, you need to update your JAVA_HOME environment variable to point at your Java 17 installation.

If you see "port 8080 already in use", another process is using that port. Close any other running servers or restart your computer to free the port.

Help me fix my Spring Boot app startup error.

✔️ Awesome, I've got everything!

Great. Double check you saw both "Started BookcatalogApplication" and "Tomcat started on port 8080" in the console output before you stopped the server.

ⓧ I'd like to double check the full code

Open pom.xml in IntelliJ and confirm it contains these key sections:

  • A <parent> block referencing spring-boot-starter-parent version 3.5.16.
  • A <java.version> property set to 17.
  • Dependencies for spring-boot-starter-web, spring-boot-starter-data-jpa, spring-boot-starter-validation, h2, and spring-boot-starter-test.

Then expand src/main/java/com/example/bookcatalog in the Project panel and confirm BookcatalogApplication.java exists with a @SpringBootApplication annotation and a main method.

Your environment is ready and your Spring Boot skeleton is running. Next up, you will build your first REST endpoint and see real JSON responses from your API.

Build Your First REST Endpoint

Your Spring Boot skeleton starts and listens on port 8080, but it does nothing useful yet. Before wiring up a full service layer, you need a visible sign of life: a working endpoint that accepts a book and gives it back as JSON.

In this step you will build the simplest possible version of your API. A JPA entity, a repository, and a controller are all it takes to get real data flowing through Spring MVC.

In this step, get ready to:
  • Create a Book entity class mapped to an H2 database table.
  • Create a repository and controller that expose POST and GET endpoints.
  • Test your API with curl and see real JSON responses.
Create the Book entity class

Every row in a database table needs a Java class to represent it. In Spring Data JPA, you annotate a plain class with @Entity and Spring handles the table creation for you.

  • In the IntelliJ project sidebar, right-click the com.example.bookcatalog package inside src/main/java.
  • Select New then Package and name it entity.
  • Right-click the new entity package, select New then Java Class, and name it Book.
  • Replace the contents of Book.java with the following code:
package com.example.bookcatalog.entity;

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

// Marks this class as a JPA entity mapped to the "books" table
@Entity
@Table(name = "books")
public class Book {

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

    private String title;

    private String author;

    private String isbn;

What does this code do?

@Entity tells Hibernate that this class represents a database table row. @Table(name = "books") sets the table name explicitly.

@Id marks the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment the ID for each new row.

  • Add the constructors and accessor methods below the isbn field, completing the class:
    // No-arg constructor required by JPA
    public Book() {
    }

    // Convenience constructor for creating books in code
    public Book(String title, String author, String isbn) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
    }

    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; }
}

Why does JPA need a no-arg constructor?

Hibernate creates entity instances using reflection, which requires an empty constructor. The parameterized constructor is for your own convenience when creating Book objects in application code.

  • Save Book.java.
  • Stop any running instance with Ctrl+C in the terminal, then restart the app by running:
./mvnw spring-boot:run
  • Confirm the console still shows "Started BookcatalogApplication" without errors. You should also see SQL output as Hibernate creates the books table.

Seeing a compilation error?

  • Check that your import statements use jakarta.persistence and not javax.persistence. Spring Boot 3.5 uses the Jakarta namespace.
  • Make sure the class has exactly one closing brace } at the very end of the file after all methods.

help me fix a compilation error in my Book entity class

✔️ Awesome, I've got everything!

Great. Confirm you saved Book.java and the app restarted without errors.

ⓧ I'd like to double check the full code

Your Book.java file exceeds 30 lines. Scroll up and confirm these sections are present in order:

  • Package declaration and five jakarta.persistence imports.
  • @Entity and @Table(name = "books") annotations above the class.
  • Four private fields: id, title, author, isbn.
  • A no-arg constructor and a three-argument constructor.
  • Getters and setters for all four fields.
  • A single closing } brace at the end.
Create the repository and controller

Spring Data JPA can generate all the SQL you need from a single interface declaration. You will pair it with a controller that maps HTTP requests to database operations.

  • Right-click the com.example.bookcatalog package, select New then Package, and name it repository.
  • Create a new Java interface inside repository called BookRepository with this content:
package com.example.bookcatalog.repository;

import com.example.bookcatalog.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

// Spring Data JPA auto-generates CRUD methods for the Book entity
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}

What does extending JpaRepository give you?

By extending JpaRepository<Book, Long>, Spring generates implementations of findAll(), findById(), save(), deleteById(), and more at runtime. You write zero SQL.

  • Create another package under com.example.bookcatalog called controller.
  • Create a new Java class inside controller called BookController with this content:
package com.example.bookcatalog.controller;

import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final BookRepository bookRepository;

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

    // Returns all books from the database as JSON
    @GetMapping
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }
}

What is constructor injection?

When a class has a single constructor, Spring automatically provides the required dependencies. This is called constructor injection and is the pattern interviewers expect to see. No @Autowired annotation is needed.

  • Save both files.
  • Stop the app with Ctrl+C and restart it:
./mvnw spring-boot:run

What do you think will happen if you request all books right now, before creating any?

  • Open a second terminal tab and test the GET endpoint by running:
curl http://localhost:8080/api/books

You should see an empty JSON array: []. The endpoint works, but the database has no books yet.

  • Now add the POST endpoint below the getAllBooks method inside BookController.java, before the closing } brace:
    // Creates a new book from the JSON request body
    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book savedBook = bookRepository.save(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
    }

What does @RequestBody do?

@RequestBody tells Spring to deserialize the incoming JSON into a Book object automatically. ResponseEntity lets you set the HTTP status code to 201 Created instead of the default 200.

  • Save BookController.java.

App fails to restart?

  • Verify that BookRepository.java is an interface (not a class) and extends JpaRepository<Book, Long>.
  • Ensure your controller imports include org.springframework.web.bind.annotation.*.

help me fix my BookController or BookRepository

✔️ Awesome, I've got everything!

Great. Confirm you saved both files and the app restarts without errors.

ⓧ I'd like to double check the full code

Here is the complete BookController.java:

package com.example.bookcatalog.controller;

import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.repository.BookRepository;
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 BookRepository bookRepository;

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

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

    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book savedBook = bookRepository.save(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
    }
}

And the complete BookRepository.java:

package com.example.bookcatalog.repository;

import com.example.bookcatalog.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}
Configure H2 and test with curl

Spring Boot auto-configures H2 when it is on the classpath, but adding explicit configuration gives you useful features like the H2 web console and visible SQL logging.

  • Open src/main/resources/application.properties in the editor.
  • Replace its contents with this configuration:
spring.application.name=bookcatalog

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

# JPA/Hibernate settings
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Enable H2 web console at /h2-console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

What do these settings do?

  • spring.jpa.hibernate.ddl-auto=create-drop recreates the schema on every startup. Perfect for development.
  • spring.jpa.show-sql=true prints every SQL statement Hibernate runs to the console so you can see what happens behind the scenes.
  • spring.h2.console.enabled=true gives you a browser-based database viewer at /h2-console.
  • Save application.properties.
  • Stop the app with Ctrl+C and restart it:
./mvnw spring-boot:run

This time you will see formatted SQL in the console as Hibernate creates the books table.

Before you test, what do you expect the response to contain when you POST a new book?

  • In your second terminal tab, POST a book by running:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884"}'

You should see the saved book returned as JSON with a generated id field:

{"id":1,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884"}
  • Confirm the book persisted by fetching all books:
curl http://localhost:8080/api/books

You should see an array containing the book you just created.

Getting a 404 or empty response?

  • Make sure the app restarted after saving application.properties. Look for "Tomcat started on port 8080" in the console.
  • Check your curl command includes -H "Content-Type: application/json". Without it, Spring cannot parse the body.
  • Verify your BookController has @RequestMapping("/api/books") at the class level.

help me debug my Spring Boot REST endpoint

Your API is alive. You can create books and retrieve them, all backed by an in-memory database. Next up, you will add a service layer to organize your business logic and discover why returning entities directly causes problems.

Add the Service Layer and Hit the Design Problem

Your API works. You can create and list books. But there is a structural issue hiding in your code.

Right now, the controller talks directly to the repository. That means your request-handling code and your business logic live in the same place. In a real app, this makes testing harder and couples your API contract tightly to your database schema. This step introduces a service layer to separate responsibilities. It also reveals a real design flaw that shows up the moment you add a new field to your entity.

In this step, get ready to:
  • Create a BookService class that encapsulates all business logic with constructor injection.
  • Refactor the controller to delegate to the service and add GET-by-ID and DELETE endpoints.
  • Observe a leaky abstraction when a new entity field bleeds into the API response.
Create the BookService class

A service layer sits between the controller and the repository. The controller handles HTTP concerns (request parsing, status codes). The service handles business logic (what to do when a book is not found, how to transform data).

  • In IntelliJ, right-click the com.example.bookcatalog package in the src/main/java folder.
  • Select New then Package and name it service.
  • Right-click the new service package and select New then Java Class. Name it BookService.
  • Replace the generated contents with this code:
package com.example.bookcatalog.service;

import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.stereotype.Service;

import java.util.List;

// Marks this class as a Spring-managed service bean
@Service
public class BookService {

    private final BookRepository bookRepository;

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

    // Returns all books from the database
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    // Finds a single book or throws if not found
    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
    }
}

What does this code do?

The @Service annotation tells Spring to manage this class as a bean. Spring automatically creates one instance and makes it available for injection elsewhere.

The constructor accepts BookRepository as a parameter. Spring sees the single constructor and injects the repository automatically. This is called constructor injection. No @Autowired annotation is needed when there is only one constructor.

  • Now add the remaining two methods below getBookById, before the closing } of the class:
    // Persists a new book entity to the database
    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    // Checks existence before deleting to provide a clear error
    public void deleteBook(Long id) {
        if (!bookRepository.existsById(id)) {
            throw new RuntimeException("Book not found with id: " + id);
        }
        bookRepository.deleteById(id);
    }

Why check existence before deleting?

The deleteById method from JpaRepository silently does nothing if the ID does not exist. By calling existsById first, you can throw a meaningful error that tells the client exactly what went wrong.

  • Save BookService.java.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Your BookService.java should contain, in order: the package declaration, imports for Book, BookRepository, Service, and List. Then the class with a bookRepository field, constructor, and four methods: getAllBooks, getBookById, createBook, deleteBook. Scroll up and confirm each chunk is present.

Refactor the controller to use BookService

The controller currently depends on BookRepository directly. You will replace that dependency with BookService and add two new endpoints.

  • Open BookController.java and replace its entire contents with this code:
package com.example.bookcatalog.controller;

import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.service.BookService;
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 {

    // Now depends on BookService instead of BookRepository
    private final BookService bookService;

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

    // Delegates list retrieval to the service layer
    @GetMapping
    public List<Book> getAllBooks() {
        return bookService.getAllBooks();
    }
}

What changed?

The import switched from BookRepository to BookService. The field and constructor now accept the service. The getAllBooks method delegates to bookService.getAllBooks() instead of calling the repository directly.

  • Now add these endpoint methods below getAllBooks, before the closing } of the class:
    // Fetches a single book by its database ID
    @GetMapping("/{id}")
    public Book getBookById(@PathVariable Long id) {
        return bookService.getBookById(id);
    }

    // Creates a new book and returns 201 Created
    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        Book savedBook = bookService.createBook(book);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
    }

    // Deletes a book and returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }

What do the new endpoints do?

  • GET /api/books/{id} retrieves a single book. The @PathVariable annotation extracts the id from the URL path.
  • DELETE /api/books/{id} removes a book and returns HTTP 204 (No Content), meaning the operation succeeded but there is no response body.
  • Save BookController.java.
  • Stop the running app in IntelliJ by pressing Ctrl+C in the terminal, then restart it by running:
./mvnw spring-boot:run
  • Once you see "Started BookcatalogApplication" in the console, test the refactored API by running:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884"}'
  • You should see the saved book returned with a generated id.
  • Now test the new GET-by-ID endpoint by running:
curl http://localhost:8080/api/books/1

You should see the single book you just created, returned as JSON.

Seeing a 500 error or "Book not found"?

  • H2 is in-memory. Data is cleared every time you restart the app. Make sure you POST a book first before trying to GET it by ID.
  • If you see a compilation error, double check that your BookController.java imports com.example.bookcatalog.service.BookService and not the old BookRepository import.

Help me debug my BookController refactoring.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Your BookController.java should contain: the package declaration, imports for Book, BookService, HttpStatus, ResponseEntity, the web annotations wildcard, and List. Then the class with a bookService field, constructor, and four endpoint methods: getAllBooks, getBookById, createBook, deleteBook. Scroll up and confirm each chunk is present.

Observe the leaky abstraction

Your refactored API works exactly like before. The service layer is in place. But there is still a hidden coupling: the controller returns the Book entity directly as JSON. This means any change to the entity's database schema immediately changes the API response your clients see.

You are about to witness this problem firsthand. You will add a new internal field to the entity and see it leak into the API with no code change in the controller.

  • Open Book.java in the entity package.
  • Find the existing import lines at the top. Add these two new imports after import jakarta.persistence.Table;:
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;
  • Find the line private String isbn; inside the class. Add this new field directly below it:
    @CreationTimestamp
    private LocalDateTime createdAt;
  • Scroll to the bottom of the class, after the setIsbn method. Add the getter and setter for the new field:
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

What is @CreationTimestamp?

This is a Hibernate-specific annotation from org.hibernate.annotations. It automatically populates the field with the current date and time exactly once, when the entity is first saved to the database. You never set this value manually.

  • Save Book.java.
  • Stop and restart the app:
./mvnw spring-boot:run

Before you run the next command, think about what you expect the response to look like. You are sending only title, author, and isbn. Will the response contain anything else?

  • POST a new book by running:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991"}'

Look at the response. The JSON now includes a createdAt field that the client never sent and never asked for.

This is the leaky abstraction

You added a field to your database entity. Without touching the controller or the service, your API response changed. The client now receives internal implementation details (createdAt) that it has no use for.

In a production application, this is dangerous. Any team member who adds a field to the entity silently changes the API contract. Clients break without warning.

Why does this happen?

When Spring serializes the Book entity to JSON, it uses Jackson to convert every getter into a JSON field. Because getCreatedAt() exists, Jackson includes createdAt in the output. Your entity and your API contract are the same object. That is the problem.

You have seen the problem firsthand. Your internal database schema is leaking into every API response. In the next step, you will fix this with the DTO pattern, giving you full control over what the client sees.

Introduce DTOs to Fix the Leaky Abstraction

In the last step, you saw the problem firsthand: the createdAt field leaked into your API response even though the client never asked for it.

This happens because your controller returns the Book entity directly. Any change to the entity automatically changes the API contract. DTOs (Data Transfer Objects) fix this by creating separate classes that define exactly what the client sends and receives, independently of your database model.

In this step, get ready to:
  • Create BookRequestDTO and BookResponseDTO as Java records.
  • Update BookService to map between DTOs and entities.
  • Update BookController to use DTO types and confirm createdAt no longer leaks.
Create the DTO records

Java records are immutable data carriers that automatically generate a constructor, getters, and equals/hashCode. They are the idiomatic way to define DTOs in Java 17.

  • In IntelliJ, right-click on the com.example.bookcatalog package in the src/main/java folder.
  • Select New, then Package.
  • Name it com.example.bookcatalog.dto and press Enter.
  • Right-click the new dto package, select New then Java Class, choose Record, and name it BookRequestDTO.
  • Replace the generated content with this code:
package com.example.bookcatalog.dto;

// Defines what the client sends when creating a book
public record BookRequestDTO(
        String title,
        String author,
        String isbn
) {
}

What does this code do?

A record with three fields means the client only needs to send title, author, and isbn when creating a book. No id (that is generated) and no createdAt (that is internal).

  • Right-click the dto package again, select New then Java Class, choose Record, and name it BookResponseDTO.
  • Replace the generated content with this code:
package com.example.bookcatalog.dto;

// Defines what the client receives back from the API
public record BookResponseDTO(
        Long id,
        String title,
        String author,
        String isbn
) {
}

Why is createdAt missing from the response DTO?

The response includes id (so the client can reference the book) but excludes createdAt. The entity still tracks the timestamp internally for auditing purposes, but the API contract stays clean and stable.

  • Save both files with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm both BookRequestDTO.java and BookResponseDTO.java appear under the dto package in the Project sidebar.

Don't see the dto package?

Make sure you created the package under src/main/java/com/example/bookcatalog, not under src/test. If the package shows as empty, check that you selected Record (not Class or Interface) in the new file dialog.

Still stuck? Help me create the dto package and record files in IntelliJ.

Update the service to use DTOs

The service layer is the boundary where entities are converted to DTOs. This keeps entity details (like createdAt) from ever reaching the controller.

  • Open BookService.java in the service package.
  • Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and replace it with the following code:
package com.example.bookcatalog.service;

import com.example.bookcatalog.dto.BookRequestDTO;
import com.example.bookcatalog.dto.BookResponseDTO;
import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.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;
    }

    // Fetches all books and maps each entity to a response DTO
    public List<BookResponseDTO> getAllBooks() {
        return bookRepository.findAll()
                .stream()
                .map(this::mapToResponseDTO)
                .toList();
    }
  • Directly below that code, add these remaining methods to complete the class:
    // Finds a single book by ID and maps to response DTO
    public BookResponseDTO getBookById(Long id) {
        Book book = bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Book not found with id: " + id));
        return mapToResponseDTO(book);
    }

    // Creates a new Book entity from the request DTO and returns the response DTO
    public BookResponseDTO createBook(BookRequestDTO requestDTO) {
        Book book = new Book(requestDTO.title(), requestDTO.author(), requestDTO.isbn());
        Book savedBook = bookRepository.save(book);
        return mapToResponseDTO(savedBook);
    }

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

    // Converts a Book entity to the response DTO, excluding internal fields
    private BookResponseDTO mapToResponseDTO(Book book) {
        return new BookResponseDTO(book.getId(), book.getTitle(), book.getAuthor(), book.getIsbn());
    }
}

What does mapToResponseDTO do?

  • mapToResponseDTO takes a Book entity and picks out only the fields the client needs: id, title, author, and isbn.
  • createBook does the reverse: it reads from the BookRequestDTO record and constructs a Book entity using the three-argument constructor.
  • The createdAt field still exists on the entity and Hibernate still populates it, but it never leaves the service layer.
  • Save BookService.java.

Seeing red underlines in BookService?

IntelliJ may show errors if you missed the import lines at the top. Make sure your file starts with package com.example.bookcatalog.service; followed by all six import statements.

Help me fix import errors in my BookService after adding DTOs.

Update the controller and verify the fix

The controller now needs to speak in DTO types instead of entity types. Once this change is in, the entity never crosses the API boundary.

  • Open BookController.java in the controller package.
  • Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and replace it with the following code:
package com.example.bookcatalog.controller;

import com.example.bookcatalog.dto.BookRequestDTO;
import com.example.bookcatalog.dto.BookResponseDTO;
import com.example.bookcatalog.service.BookService;
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<BookResponseDTO> getAllBooks() {
        return bookService.getAllBooks();
    }

    @GetMapping("/{id}")
    public BookResponseDTO getBookById(@PathVariable Long id) {
        return bookService.getBookById(id);
    }
  • Directly below that code, add the remaining endpoints to complete the class:
    @PostMapping
    public ResponseEntity<BookResponseDTO> createBook(@RequestBody BookRequestDTO requestDTO) {
        BookResponseDTO createdBook = bookService.createBook(requestDTO);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdBook);
    }

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

What changed in the controller?

  • The Book entity import is gone. The controller now only knows about BookRequestDTO and BookResponseDTO.
  • createBook accepts a BookRequestDTO parameter, so the client can never accidentally send id or createdAt.
  • All return types use BookResponseDTO, which guarantees createdAt never appears in responses.
  • Save BookController.java.
  • Stop the running app with Ctrl+C in the IntelliJ terminal, then restart it by running:
./mvnw spring-boot:run

Before you test with curl, predict: will the response still contain createdAt, or will it be gone?

  • Open a second terminal tab and POST a new book by running:
curl -X POST http://localhost:8080/api/books -H "Content-Type: application/json" -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991"}'

The response contains only id, title, author, and isbn. The createdAt field no longer appears in the API response.

Why does this matter in interviews?

Interviewers ask about DTOs because they test whether you understand separation of concerns. The entity handles persistence. The DTO handles the API contract. Changing one never accidentally breaks the other.

Still seeing createdAt in the response?

Make sure you restarted the app after saving all three files. If the response still shows extra fields, double-check that BookController imports from com.example.bookcatalog.dto and not from the entity package.

Help me figure out why createdAt still shows in my API response after adding DTOs.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three updated files: BookRequestDTO.java, BookResponseDTO.java, BookService.java, and BookController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these code blocks is present and matches what you pasted:

  • BookRequestDTO.java in the dto package with three fields: title, author, isbn.
  • BookResponseDTO.java in the dto package with four fields: id, title, author, isbn.
  • BookService.java with getAllBooks, getBookById, createBook, deleteBook, and the private mapToResponseDTO helper method.
  • BookController.java importing from com.example.bookcatalog.dto with all four endpoints using DTO types.

Your API contract is now decoupled from your database schema. Next up, you will write unit tests that prove your service logic works correctly, using Mockito to mock the repository.

Write Unit Tests with JUnit 5 and Mockito

Your API has clean DTOs, a service layer, and working endpoints. But how do you prove the service logic is correct without spinning up the entire application and database?

Unit tests let you verify individual methods in isolation. By replacing the real repository with a mock (a fake stand-in), you test only the service logic. This is the testing approach interviewers specifically ask about in backend Java roles.

In this step, get ready to:
  • Create a test class with Mockito annotations for mock injection.
  • Write a test proving the service maps repository results to DTOs.
  • Write a test that captures the entity passed to the repository and asserts on it.
Create the test class with mock annotations

Your spring-boot-starter-test dependency already includes JUnit 5 and Mockito. No new dependencies needed.

  • In IntelliJ, right-click the src/test/java/com/example/bookcatalog folder in the Project panel.
  • Select New then Package.
  • Enter service as the package name.
  • Right-click the new service package and select New then Java Class.
  • Name it BookServiceTest and paste the following code:
package com.example.bookcatalog.service;

import com.example.bookcatalog.dto.BookRequestDTO;
import com.example.bookcatalog.dto.BookResponseDTO;
import com.example.bookcatalog.entity.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class) // Activates Mockito annotations
class BookServiceTest {

    @Mock // Creates a fake BookRepository (no real database)
    private BookRepository bookRepository;

    @InjectMocks // Creates a real BookService, injecting the mock above
    private BookService bookService;
}

What do these annotations do?

`@ExtendWith(MockitoExtension.class)` tells JUnit 5 to activate Mockito. It automatically creates any fields annotated with `@Mock` and injects them into the `@InjectMocks` target.

`@Mock` creates a fake `BookRepository` that returns nothing by default. `@InjectMocks` creates a real `BookService` and passes the mock repository into its constructor. Your service thinks it has a real database connection, but you control exactly what it returns.

  • Save BookServiceTest.java.
Write a test for getAllBooks

Your first test proves that getAllBooks() correctly maps repository results into DTOs. You tell the mock what to return, call the service, and assert on the output.

  • Inside the BookServiceTest class (before the closing }), add this test method:
    @Test
    void getAllBooks_returnsListOfBookResponseDTOs() {
        // Arrange: create a Book and tell the mock to return it
        Book book = new Book("Clean Code", "Robert Martin", "9780132350884");
        book.setId(1L);
        when(bookRepository.findAll()).thenReturn(List.of(book));

        // Act: call the service method
        List<BookResponseDTO> result = bookService.getAllBooks();

        // Assert: verify the result was mapped to a DTO correctly
        assertEquals(1, result.size());
        assertEquals("Clean Code", result.get(0).title());
        assertEquals("Robert Martin", result.get(0).author());
    }

What does this test do?

  • `when(bookRepository.findAll()).thenReturn(...)` programs the mock. When the service calls `findAll()`, the mock returns your predefined book instead of querying a database.
  • The test then calls `bookService.getAllBooks()` and asserts the result is a list of `BookResponseDTO` objects with the correct values.
  • This proves the service's mapping logic works without needing H2 or any database.
  • Save BookServiceTest.java.
  • Run the tests by executing this in the IntelliJ terminal:
./mvnw test

You should see output indicating 1 test ran with 0 failures.

Tests not running?

  • Make sure you saved the file before running the command.
  • Check that your test class is in src/test/java (not src/main/java). Maven only runs tests from the test source tree.
  • Verify the class has the @ExtendWith(MockitoExtension.class) annotation. Without it, the @Mock fields stay null.

Still stuck? Help me debug why my BookServiceTest won't run.

Write a test for createBook with ArgumentCaptor

The getAllBooks test checked output. This next test checks input. You need to verify that when createBook() is called with a DTO, the service passes the correct entity to the repository's save() method.

An ArgumentCaptor intercepts the actual object passed to a mock, so you can inspect its fields after the fact.

  • Add this second test method below the first one (still inside the class):
    @Test
    void createBook_savesEntityAndReturnsDTO() {
        // Arrange: prepare a request DTO and mock the save result
        BookRequestDTO request = new BookRequestDTO("Effective Java", "Joshua Bloch", "9780134685991");
        Book savedBook = new Book("Effective Java", "Joshua Bloch", "9780134685991");
        savedBook.setId(1L);
        when(bookRepository.save(any(Book.class))).thenReturn(savedBook);

        // Act: call createBook on the service
        BookResponseDTO result = bookService.createBook(request);

        // Assert: check the returned DTO
        assertNotNull(result);
        assertEquals(1L, result.id());
        assertEquals("Effective Java", result.title());

        // Capture the entity that was passed to repository.save()
        ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
        verify(bookRepository).save(captor.capture());
        assertEquals("Effective Java", captor.getValue().getTitle());
        assertEquals("9780134685991", captor.getValue().getIsbn());
    }

What does ArgumentCaptor prove here?

`verify(bookRepository).save(captor.capture())` confirms the repository's `save()` was called exactly once. The captor grabs the `Book` entity that was passed in.

`captor.getValue().getTitle()` then lets you assert that the service constructed the entity correctly from the DTO. This proves the DTO-to-entity mapping works. The test checks both the output (the returned DTO) and the internal behavior (what was saved to the repository).

  • Save BookServiceTest.java.

Before you run the tests, what do you expect to see? Both tests should pass because the service logic is correct.

  • Run all tests:
./mvnw test

You should see output containing Tests run: 2, Failures: 0, Errors: 0 along with a BUILD SUCCESS message.

Seeing test failures?

  • If you see a NullPointerException, make sure the @Mock and @InjectMocks annotations are on the fields, not inside a method.
  • If a test fails on an assertion, double-check the book titles and ISBNs in your test match the code above exactly.
  • If you see a compilation error about missing imports, verify all the static imports at the top of the file (assertEquals, when, verify, any) are present.

Still stuck? Help me debug my failing BookServiceTest.

✔️ Awesome, I've got everything!

Great. Double-check you have saved BookServiceTest.java and both tests pass.

ⓧ I'd like to double check the full code

Your BookServiceTest.java should contain these sections in order:

  • The package declaration, all imports, the @ExtendWith annotation, the class declaration, and the @Mock / @InjectMocks fields (from the first code block above).
  • The getAllBooks_returnsListOfBookResponseDTOs test method (from the second code block).
  • The createBook_savesEntityAndReturnsDTO test method (from the third code block).
  • A closing } for the class.

Scroll up and confirm each section is present in your file. If ./mvnw test shows 2 tests passing with 0 failures, you are good.

You now have tested, proven service logic. Your unit tests run in milliseconds with no database, no network, and no Spring context. Next up, the Secret Mission challenges you to add proper exception handling so your API returns structured error responses instead of raw stack traces.

Secret mission

Add Global Exception Handling with @ControllerAdvice

Right now, requesting a non-existent book returns a generic 500 error with a stack trace. In production and in interviews, you need predictable error responses with proper HTTP status codes. This mission adds a @ControllerAdvice handler that returns structured JSON errors.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. The H2 database is in-memory, meaning all data disappears the moment you stop the Spring Boot server.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The bookcatalog project folder (all source code, tests, and Maven wrapper).
  • IntelliJ IDEA (free IDE, installed permanently on your machine).
  • H2 in-memory database (lives inside the running app process, no separate server).

Keep everything running

No action needed. Choose this if you plan to keep developing the API or want to revisit the code later.

  • Your project folder stays on your machine with zero running costs.
  • IntelliJ IDEA is free and useful for future Java projects.
  • The H2 database only exists while the app is running. No data persists between restarts.
  • Consider pushing your project to GitHub to use as a portfolio piece.

Pause - I'll come back to this later

Stop the running Spring Boot server to free up memory and your terminal.

  • Switch to the terminal where your Spring Boot app is running.
  • Press Ctrl+C to stop the server.
  • Your project files remain on disk. Run ./mvnw spring-boot:run from the project folder to start up again anytime.

Delete - I don't want to use this again

Remove all project files from your machine. IntelliJ IDEA can stay since it is free and useful for other work.

  • Stop the Spring Boot server if it is still running by pressing Ctrl+C in the terminal.
  • Delete the bookcatalog project folder by running the command for your operating system:

macOS

rm -rf ~/Desktop/bookcatalog

Windows

rmdir /s /q %USERPROFILE%\Desktop\bookcatalog

If you unzipped the project to a different location, navigate there and delete the bookcatalog folder instead.

  • No database to clean up. H2 ran in-memory and all data was already lost when you stopped the server.

Nice Work!

Nice Work!

Nice work! You just built a professional-grade Spring Boot REST API from scratch, complete with clean architecture, database persistence, and unit tests.

You've learned how to:

  • Build a Spring Boot 3.5 REST API with full CRUD operations and clean layered architecture (Controller, Service, Repository) using constructor injection throughout.
  • Apply the DTO pattern to decouple your API contract from your persistence model, keeping internal fields like createdAt from leaking into API responses.
  • Write unit tests with JUnit 5 and Mockito to prove service logic works in isolation, using @Mock, @InjectMocks, and ArgumentCaptor.
  • Secret Mission: Added global exception handling with @RestControllerAdvice, custom exceptions, proper HTTP status codes, and bean validation with @NotBlank and @Valid.

Ready to quiz yourself?