
You’ve learned Spring Boot basics. Now it’s time to build something real. This project creates a complete REST API for managing a book collection. It covers all the patterns you’ll use in production: layered architecture, database persistence, validation, error handling, and testing.
By the end, you’ll have an API that handles CRUD operations, validates input, returns proper HTTP status codes, and persists data to a database.
What We’re Building
A Book Management API with these endpoints:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/books | List all books |
| GET | /api/books/{id} | Get a single book |
| POST | /api/books | Create a new book |
| PUT | /api/books/{id} | Update an existing book |
| DELETE | /api/books/{id} | Delete a book |
| GET | /api/books/search?author=X | Search by author |
Project Setup
Go to start.spring.io and create a new project:
- Project: Maven
- Language: Java
- Spring Boot: 3.2.x
- Group: com.example
- Artifact: bookapi
- Packaging: Jar
- Java: 21
Add these dependencies:
- Spring Web
- Spring Data JPA
- H2 Database
- Validation
Download, extract, and open in your IDE.
Project Structure
We’ll organize code by layer:
src/main/java/com/example/bookapi/
BookapiApplication.java
model/
Book.java
repository/
BookRepository.java
service/
BookService.java
controller/
BookController.java
exception/
BookNotFoundException.java
GlobalExceptionHandler.java
dto/
BookRequest.java
BookResponse.java
Step 1: The Entity
Create the Book entity that maps to a database table:
package com.example.bookapi.model;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private String isbn;
@Column(name = "published_date")
private LocalDate publishedDate;
private String genre;
@Column(length = 2000)
private String description;
// Default constructor required by JPA
public Book() {}
public Book(String title, String author) {
this.title = title;
this.author = author;
}
// Getters and setters
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 LocalDate getPublishedDate() { return publishedDate; }
public void setPublishedDate(LocalDate publishedDate) { this.publishedDate = publishedDate; }
public String getGenre() { return genre; }
public void setGenre(String genre) { this.genre = genre; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
Step 2: The Repository
Spring Data JPA generates the implementation from an interface:
package com.example.bookapi.repository;
import com.example.bookapi.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
// Spring generates these queries from method names
List<Book> findByAuthorContainingIgnoreCase(String author);
List<Book> findByGenreIgnoreCase(String genre);
List<Book> findByTitleContainingIgnoreCase(String title);
boolean existsByIsbn(String isbn);
}
JpaRepository provides findAll(), findById(), save(), deleteById(), and more. The custom methods are derived from their names automatically.
Step 3: DTOs for Request/Response
DTOs (Data Transfer Objects) separate your API contract from your internal model:
package com.example.bookapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public class BookRequest {
@NotBlank(message = "Title is required")
@Size(max = 200, message = "Title must be less than 200 characters")
private String title;
@NotBlank(message = "Author is required")
@Size(max = 100, message = "Author must be less than 100 characters")
private String author;
private String isbn;
private LocalDate publishedDate;
private String genre;
@Size(max = 2000, message = "Description must be less than 2000 characters")
private String description;
// Getters and setters
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 LocalDate getPublishedDate() { return publishedDate; }
public void setPublishedDate(LocalDate publishedDate) { this.publishedDate = publishedDate; }
public String getGenre() { return genre; }
public void setGenre(String genre) { this.genre = genre; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
package com.example.bookapi.dto;
import com.example.bookapi.model.Book;
import java.time.LocalDate;
public class BookResponse {
private Long id;
private String title;
private String author;
private String isbn;
private LocalDate publishedDate;
private String genre;
private String description;
public BookResponse(Book book) {
this.id = book.getId();
this.title = book.getTitle();
this.author = book.getAuthor();
this.isbn = book.getIsbn();
this.publishedDate = book.getPublishedDate();
this.genre = book.getGenre();
this.description = book.getDescription();
}
// Getters
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getIsbn() { return isbn; }
public LocalDate getPublishedDate() { return publishedDate; }
public String getGenre() { return genre; }
public String getDescription() { return description; }
}
Step 4: Custom Exception
package com.example.bookapi.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
}
Step 5: The Service Layer
The service contains business logic and coordinates between controller and repository:
package com.example.bookapi.service;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.dto.BookResponse;
import com.example.bookapi.exception.BookNotFoundException;
import com.example.bookapi.model.Book;
import com.example.bookapi.repository.BookRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<BookResponse> getAllBooks() {
return bookRepository.findAll().stream()
.map(BookResponse::new)
.collect(Collectors.toList());
}
public BookResponse getBookById(Long id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
return new BookResponse(book);
}
public BookResponse createBook(BookRequest request) {
Book book = new Book();
mapRequestToBook(request, book);
Book saved = bookRepository.save(book);
return new BookResponse(saved);
}
public BookResponse updateBook(Long id, BookRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
mapRequestToBook(request, book);
Book updated = bookRepository.save(book);
return new BookResponse(updated);
}
public void deleteBook(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException(id);
}
bookRepository.deleteById(id);
}
public List<BookResponse> searchByAuthor(String author) {
return bookRepository.findByAuthorContainingIgnoreCase(author).stream()
.map(BookResponse::new)
.collect(Collectors.toList());
}
public List<BookResponse> searchByGenre(String genre) {
return bookRepository.findByGenreIgnoreCase(genre).stream()
.map(BookResponse::new)
.collect(Collectors.toList());
}
private void mapRequestToBook(BookRequest request, Book book) {
book.setTitle(request.getTitle());
book.setAuthor(request.getAuthor());
book.setIsbn(request.getIsbn());
book.setPublishedDate(request.getPublishedDate());
book.setGenre(request.getGenre());
book.setDescription(request.getDescription());
}
}
Step 6: The Controller
The controller handles HTTP requests and delegates to the service:
package com.example.bookapi.controller;
import com.example.bookapi.dto.BookRequest;
import com.example.bookapi.dto.BookResponse;
import com.example.bookapi.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public List<BookResponse> getAllBooks() {
return bookService.getAllBooks();
}
@GetMapping("/{id}")
public BookResponse getBook(@PathVariable Long id) {
return bookService.getBookById(id);
}
@PostMapping
public ResponseEntity<BookResponse> createBook(@Valid @RequestBody BookRequest request) {
BookResponse created = bookService.createBook(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public BookResponse updateBook(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
return bookService.updateBook(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
public List<BookResponse> searchBooks(
@RequestParam(required = false) String author,
@RequestParam(required = false) String genre) {
if (author != null && !author.isBlank()) {
return bookService.searchByAuthor(author);
}
if (genre != null && !genre.isBlank()) {
return bookService.searchByGenre(genre);
}
return bookService.getAllBooks();
}
}
The @Valid annotation triggers validation on BookRequest. Invalid requests get rejected before reaching the service.
Step 7: Global Exception Handler
Handle exceptions consistently across all endpoints:
package com.example.bookapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleBookNotFound(BookNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(error.getField(), error.getDefaultMessage());
}
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("errors", fieldErrors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.badRequest().body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
// Inner class for error responses
public static class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
public ErrorResponse(int status, String message, LocalDateTime timestamp) {
this.status = status;
this.message = message;
this.timestamp = timestamp;
}
public int getStatus() { return status; }
public String getMessage() { return message; }
public LocalDateTime getTimestamp() { return timestamp; }
}
}
Step 8: Configuration
Configure the database in application.properties:
# H2 Database (in-memory for development)
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
# H2 Console (access at /h2-console)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# Server port
server.port=8080
Step 9: Sample Data (Optional)
Add sample data on startup for testing:
package com.example.bookapi;
import com.example.bookapi.model.Book;
import com.example.bookapi.repository.BookRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
@Configuration
public class DataLoader {
@Bean
CommandLineRunner initDatabase(BookRepository repository) {
return args -> {
Book book1 = new Book("The Pragmatic Programmer", "David Thomas");
book1.setIsbn("978-0135957059");
book1.setGenre("Technology");
book1.setPublishedDate(LocalDate.of(2019, 9, 13));
book1.setDescription("A guide to software craftsmanship.");
repository.save(book1);
Book book2 = new Book("Clean Code", "Robert C. Martin");
book2.setIsbn("978-0132350884");
book2.setGenre("Technology");
book2.setPublishedDate(LocalDate.of(2008, 8, 1));
book2.setDescription("A handbook of agile software craftsmanship.");
repository.save(book2);
Book book3 = new Book("1984", "George Orwell");
book3.setIsbn("978-0451524935");
book3.setGenre("Fiction");
book3.setPublishedDate(LocalDate.of(1949, 6, 8));
book3.setDescription("A dystopian social science fiction novel.");
repository.save(book3);
System.out.println("Sample data loaded: " + repository.count() + " books");
};
}
}
Testing the API
Run the application and test with curl or Postman.
Get All Books
curl http://localhost:8080/api/books
Response:
[
{
"id": 1,
"title": "The Pragmatic Programmer",
"author": "David Thomas",
"isbn": "978-0135957059",
"publishedDate": "2019-09-13",
"genre": "Technology",
"description": "A guide to software craftsmanship."
},
...
]
Get Single Book
curl http://localhost:8080/api/books/1
Create a Book
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "Effective Java",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"genre": "Technology",
"publishedDate": "2018-01-06"
}'
Response (201 Created):
{
"id": 4,
"title": "Effective Java",
"author": "Joshua Bloch",
...
}
Update a Book
curl -X PUT http://localhost:8080/api/books/4 \
-H "Content-Type: application/json" \
-d '{
"title": "Effective Java, 3rd Edition",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"genre": "Technology"
}'
Delete a Book
curl -X DELETE http://localhost:8080/api/books/4
Response: 204 No Content
Search by Author
curl "http://localhost:8080/api/books/search?author=martin"
Validation Error
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title": "", "author": ""}'
Response (400 Bad Request):
{
"status": 400,
"errors": {
"title": "Title is required",
"author": "Author is required"
},
"timestamp": "2026-01-02T14:30:00"
}
Not Found Error
curl http://localhost:8080/api/books/999
Response (404 Not Found):
{
"status": 404,
"message": "Book not found with id: 999",
"timestamp": "2026-01-02T14:30:00"
}
What You Built
This project demonstrates patterns used in production APIs:
Layered architecture: Controller, Service, Repository separation. Each layer has clear responsibilities.
DTOs: Separate request/response objects from internal entities. Change your database model without breaking the API.
Validation: Input validation with meaningful error messages before data reaches your business logic.
Exception handling: Consistent error responses across all endpoints. Clients know what to expect.
Dependency injection: Spring wires components together. Easy to test and swap implementations.
Next Steps
Extend the project with these features:
Pagination: Return books in pages instead of all at once. Use Spring Data’s Pageable.
Authentication: Add Spring Security to protect endpoints. JWT tokens for stateless auth.
PostgreSQL: Replace H2 with a real database for production use.
Docker: Containerize the application for deployment.
OpenAPI/Swagger: Generate API documentation automatically.
Unit tests: Test service logic with mocked repositories.
Integration tests: Test the full request/response cycle.
Prerequisites: Introduction to Spring Boot | Introduction to JDBC | Introduction to Maven
Related: Build a To-Do List App | Java Collections Framework | Java Exception Handling


