Lab: Adding Data Persistence to the 'Book Catalog' Application


In this lab, we’ll extend our Book Catalog REST API to store book data in a database using Spring Data JPA. We’ll first use H2 as an in-memory database for development and then switch to PostgreSQL for a more production-like setup.

Objectives:

  • Configure Spring Data JPA with H2 database
  • Create JPA entities and repositories
  • Modify the service layer to use JPA repositories
  • Add data validation
  • Implement pagination and sorting
  • Add database transaction management
  • Configure a PostgreSQL database
  • Implement database migrations with Flyway

Prerequisites:

  • Completed “Book Catalog” REST API from Module 3
  • Java Development Kit (JDK) 17 or later
  • Maven
  • PostgreSQL (for the second part of the lab)

Step 1: Add Spring Data JPA and H2 Database Dependencies

  1. Open your pom.xml file and add the following dependencies:
<!-- Spring Data JPA -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- H2 Database -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- Validation API -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Step 2: Configure H2 Database in application.properties

  1. Open or create src/main/resources/application.properties and add the following configuration:
# Server port
server.port=8081

# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

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

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

# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings

# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0

Step 3: Update the Book Model as a JPA Entity

  1. Modify the Book class to make it a JPA entity:
package com.example.bookcatalog.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.time.LocalDate;

@Entity
@Table(name = "books")
public class Book {

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

    @NotBlank(message = "Title is required")
    @Size(max = 100, message = "Title cannot exceed 100 characters")
    @Column(nullable = false, length = 100)
    private String title;

    @Size(max = 200, message = "Author name cannot exceed 200 characters")
    @Column(length = 200)
    private String author;

    @Pattern(regexp = "^\\d{10}|\\d{13}$", message = "ISBN must be 10 or 13 digits")
    @Column(length = 20, unique = true)
    private String isbn;

    @Past(message = "Publication date must be in the past")
    @Column(name = "publication_date")
    private LocalDate publicationDate;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Min(value = 1, message = "Page count must be at least 1")
    @Column(name = "page_count")
    private Integer pageCount;

    // Default constructor (required by JPA)
    public Book() {
    }

    // Constructor with fields
    public Book(String title, String author, String isbn, LocalDate publicationDate,
                String description, Integer pageCount) {
        this.title = title;
        this.author = author;
        this.isbn = isbn;
        this.publicationDate = publicationDate;
        this.description = description;
        this.pageCount = pageCount;
    }

    // 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 getPublicationDate() {
        return publicationDate;
    }

    public void setPublicationDate(LocalDate publicationDate) {
        this.publicationDate = publicationDate;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Integer getPageCount() {
        return pageCount;
    }

    public void setPageCount(Integer pageCount) {
        this.pageCount = pageCount;
    }

    // equals, hashCode, toString methods
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Book book = (Book) o;
        return Objects.equals(id, book.id) &&
               Objects.equals(isbn, book.isbn);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, isbn);
    }

    @Override
    public String toString() {
        return "Book{" +
               "id=" + id +
               ", title='" + title + '\'' +
               ", author='" + author + '\'' +
               ", isbn='" + isbn + '\'' +
               ", publicationDate=" + publicationDate +
               ", pageCount=" + pageCount +
               '}';
    }
}
  1. Don’t forget to add the necessary import for java.util.Objects:
import java.util.Objects;

Step 4: Create a Book Repository Interface

  1. Create a new interface BookRepository in the repository package:
package com.example.bookcatalog.repository;

import com.example.bookcatalog.model.Book;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

    // Find book by ISBN
    Optional<Book> findByIsbn(String isbn);

    // Find books by title containing a string (case-insensitive)
    List<Book> findByTitleContainingIgnoreCase(String title);

    // Find books by author containing a string (case-insensitive)
    List<Book> findByAuthorContainingIgnoreCase(String author);

    // Find books published after a certain date
    List<Book> findByPublicationDateAfter(LocalDate date);

    // Find books with page count greater than a value
    List<Book> findByPageCountGreaterThan(Integer pageCount);

    // Custom query to search books by title or author
    @Query("SELECT b FROM Book b WHERE LOWER(b.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(b.author) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    List<Book> searchBooks(@Param("keyword") String keyword);

    // Custom query to find books published in a specific year
    @Query("SELECT b FROM Book b WHERE YEAR(b.publicationDate) = :year")
    List<Book> findBooksByPublicationYear(@Param("year") int year);

    // Paginated search
    Page<Book> findByTitleContainingIgnoreCaseOrAuthorContainingIgnoreCase(
        String title, String author, Pageable pageable);
}

Step 5: Update the Book Service to Use JPA Repository

  1. Update the BookService interface:
package com.example.bookcatalog.service;

import com.example.bookcatalog.model.Book;
import org.springframework.data.domain.Page;
import java.util.List;

public interface BookService {
    List<Book> findAllBooks();
    Book findBookById(Long id);
    Book findBookByIsbn(String isbn);
    Book createBook(Book book);
    Book updateBook(Long id, Book bookDetails);
    void deleteBook(Long id);
    List<Book> searchBooks(String keyword);
    Page<Book> findBooksPaginated(int page, int size, String sortBy, String direction);
    List<Book> findBooksByYear(int year);
}
  1. Update the BookServiceImpl class:
package com.example.bookcatalog.service.impl;

import com.example.bookcatalog.exception.ResourceNotFoundException;
import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import com.example.bookcatalog.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class BookServiceImpl implements BookService {

    private final BookRepository bookRepository;

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

    @Override
    @Transactional(readOnly = true)
    public List<Book> findAllBooks() {
        return bookRepository.findAll();
    }

    @Override
    @Transactional(readOnly = true)
    public Book findBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id));
    }

    @Override
    @Transactional(readOnly = true)
    public Book findBookByIsbn(String isbn) {
        return bookRepository.findByIsbn(isbn)
                .orElseThrow(() -> new ResourceNotFoundException("Book not found with ISBN: " + isbn));
    }

    @Override
    @Transactional
    public Book createBook(Book book) {
        // Check if a book with the same ISBN already exists
        bookRepository.findByIsbn(book.getIsbn()).ifPresent(existingBook -> {
            throw new IllegalArgumentException("Book with ISBN " + book.getIsbn() + " already exists");
        });

        return bookRepository.save(book);
    }

    @Override
    @Transactional
    public Book updateBook(Long id, Book bookDetails) {
        Book book = findBookById(id);

        // If ISBN is being changed, check if the new ISBN already exists on another book
        if (!book.getIsbn().equals(bookDetails.getIsbn())) {
            bookRepository.findByIsbn(bookDetails.getIsbn()).ifPresent(existingBook -> {
                if (!existingBook.getId().equals(id)) {
                    throw new IllegalArgumentException("Book with ISBN " + bookDetails.getIsbn() + " already exists");
                }
            });
        }

        book.setTitle(bookDetails.getTitle());
        book.setAuthor(bookDetails.getAuthor());
        book.setIsbn(bookDetails.getIsbn());
        book.setPublicationDate(bookDetails.getPublicationDate());
        book.setDescription(bookDetails.getDescription());
        book.setPageCount(bookDetails.getPageCount());

        return bookRepository.save(book);
    }

    @Override
    @Transactional
    public void deleteBook(Long id) {
        Book book = findBookById(id);
        bookRepository.delete(book);
    }

    @Override
    @Transactional(readOnly = true)
    public List<Book> searchBooks(String keyword) {
        return bookRepository.searchBooks(keyword);
    }

    @Override
    @Transactional(readOnly = true)
    public Page<Book> findBooksPaginated(int page, int size, String sortBy, String direction) {
        Sort sort = direction.equalsIgnoreCase(Sort.Direction.ASC.name()) ?
                Sort.by(sortBy).ascending() : Sort.by(sortBy).descending();

        Pageable pageable = PageRequest.of(page, size, sort);
        return bookRepository.findAll(pageable);
    }

    @Override
    @Transactional(readOnly = true)
    public List<Book> findBooksByYear(int year) {
        return bookRepository.findBooksByPublicationYear(year);
    }
}

Step 6: Create Exception Handling Classes

  1. Create or update ResourceNotFoundException class:
package com.example.bookcatalog.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  1. Create a global exception handler:
package com.example.bookcatalog.exception;

import jakarta.validation.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
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 org.springframework.web.context.request.WebRequest;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<?> handleResourceNotFoundException(
            ResourceNotFoundException ex, WebRequest request) {

        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                ex.getMessage(),
                request.getDescription(false));

        return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<?> handleIllegalArgumentException(
            IllegalArgumentException ex, WebRequest request) {

        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                ex.getMessage(),
                request.getDescription(false));

        return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });

        ValidationErrorDetails errorDetails = new ValidationErrorDetails(
                LocalDateTime.now(),
                "Validation failed",
                errors);

        return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<?> handleConstraintViolationException(
            ConstraintViolationException ex, WebRequest request) {

        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                ex.getMessage(),
                request.getDescription(false));

        return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<?> handleDataIntegrityViolationException(
            DataIntegrityViolationException ex, WebRequest request) {

        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                "Database integrity constraint violated: " + ex.getMostSpecificCause().getMessage(),
                request.getDescription(false));

        return new ResponseEntity<>(errorDetails, HttpStatus.CONFLICT);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleGlobalException(
            Exception ex, WebRequest request) {

        ErrorDetails errorDetails = new ErrorDetails(
                LocalDateTime.now(),
                ex.getMessage(),
                request.getDescription(false));

        return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
  1. Create error response classes:
package com.example.bookcatalog.exception;

import java.time.LocalDateTime;

public class ErrorDetails {
    private LocalDateTime timestamp;
    private String message;
    private String details;

    public ErrorDetails(LocalDateTime timestamp, String message, String details) {
        this.timestamp = timestamp;
        this.message = message;
        this.details = details;
    }

    // Getters
    public LocalDateTime getTimestamp() {
        return timestamp;
    }

    public String getMessage() {
        return message;
    }

    public String getDetails() {
        return details;
    }
}
package com.example.bookcatalog.exception;

import java.time.LocalDateTime;
import java.util.Map;

public class ValidationErrorDetails {
    private LocalDateTime timestamp;
    private String message;
    private Map<String, String> errors;

    public ValidationErrorDetails(LocalDateTime timestamp, String message, Map<String, String> errors) {
        this.timestamp = timestamp;
        this.message = message;
        this.errors = errors;
    }

    // Getters
    public LocalDateTime getTimestamp() {
        return timestamp;
    }

    public String getMessage() {
        return message;
    }

    public Map<String, String> getErrors() {
        return errors;
    }
}

Step 7: Update Book Controller to Support Pagination

package com.example.bookcatalog.controller;

import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.service.BookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/books")
@Tag(name = "Book Controller", description = "Operations pertaining to books in the catalog")
public class BookController {

    private final BookService bookService;

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

    @Operation(summary = "Get all books", description = "Returns a list of all books in the catalog")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved books",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) })
    })
    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        List<Book> books = bookService.findAllBooks();
        return ResponseEntity.ok(books);
    }

    @Operation(summary = "Get paginated books", description = "Returns a paginated list of books with sorting")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved paginated books",
                    content = { @Content(mediaType = "application/json") })
    })
    @GetMapping("/paginated")
    public ResponseEntity<Page<Book>> getPaginatedBooks(
            @Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page,
            @Parameter(description = "Page size") @RequestParam(defaultValue = "10") int size,
            @Parameter(description = "Sort field") @RequestParam(defaultValue = "id") String sort,
            @Parameter(description = "Sort direction") @RequestParam(defaultValue = "asc") String direction) {

        Page<Book> books = bookService.findBooksPaginated(page, size, sort, direction);
        return ResponseEntity.ok(books);
    }

    @Operation(summary = "Get a book by ID", description = "Returns a single book identified by its ID")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved book",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) }),
        @ApiResponse(responseCode = "404", description = "Book not found",
                    content = @Content)
    })
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(
            @Parameter(description = "ID of the book to be retrieved") @PathVariable Long id) {
        Book book = bookService.findBookById(id);
        return ResponseEntity.ok(book);
    }

    @Operation(summary = "Get a book by ISBN", description = "Returns a single book identified by its ISBN")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved book",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) }),
        @ApiResponse(responseCode = "404", description = "Book not found",
                    content = @Content)
    })
    @GetMapping("/isbn/{isbn}")
    public ResponseEntity<Book> getBookByIsbn(
            @Parameter(description = "ISBN of the book to be retrieved") @PathVariable String isbn) {
        Book book = bookService.findBookByIsbn(isbn);
        return ResponseEntity.ok(book);
    }

    @Operation(summary = "Search books", description = "Returns books matching the search keyword in title or author")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved books",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) })
    })
    @GetMapping("/search")
    public ResponseEntity<List<Book>> searchBooks(
            @Parameter(description = "Search keyword") @RequestParam String keyword) {
        List<Book> books = bookService.searchBooks(keyword);
        return ResponseEntity.ok(books);
    }

    @Operation(summary = "Find books by publication year", description = "Returns books published in a specific year")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Successfully retrieved books",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) })
    })
    @GetMapping("/year/{year}")
    public ResponseEntity<List<Book>> getBooksByYear(
            @Parameter(description = "Publication year") @PathVariable int year) {
        List<Book> books = bookService.findBooksByYear(year);
        return ResponseEntity.ok(books);
    }

    @Operation(summary = "Create a new book", description = "Adds a new book to the catalog")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "201", description = "Book successfully created",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) }),
        @ApiResponse(responseCode = "400", description = "Invalid input",
                    content = @Content)
    })
    @PostMapping
    public ResponseEntity<Book> createBook(
            @Parameter(description = "Book object to be added") @Valid @RequestBody Book book) {
        Book createdBook = bookService.createBook(book);
        return new ResponseEntity<>(createdBook, HttpStatus.CREATED);
    }

    @Operation(summary = "Update a book", description = "Updates an existing book in the catalog")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "Book successfully updated",
                    content = { @Content(mediaType = "application/json",
                                schema = @Schema(implementation = Book.class)) }),
        @ApiResponse(responseCode = "400", description = "Invalid input",
                    content = @Content),
        @ApiResponse(responseCode = "404", description = "Book not found",
                    content = @Content)
    })
    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(
            @Parameter(description = "ID of the book to be updated") @PathVariable Long id,
            @Parameter(description = "Updated book object") @Valid @RequestBody Book book) {
        Book updatedBook = bookService.updateBook(id, book);
        return ResponseEntity.ok(updatedBook);
    }

    @Operation(summary = "Delete a book", description = "Removes a book from the catalog")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "204", description = "Book successfully deleted",
                    content = @Content),
        @ApiResponse(responseCode = "404", description = "Book not found",
                    content = @Content)
    })
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(
            @Parameter(description = "ID of the book to be deleted") @PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}

Step 8: Create a Data Initializer

Create a class to load initial data when the application starts:

package com.example.bookcatalog.config;

import com.example.bookcatalog.model.Book;
import com.example.bookcatalog.repository.BookRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

import java.time.LocalDate;

@Configuration
public class DataInitializer {

    @Bean
    @Profile("!prod") // This bean will not be loaded in production
    public CommandLineRunner loadData(BookRepository bookRepository) {
        return args -> {
            // Only initialize if the database is empty
            if (bookRepository.count() == 0) {
                bookRepository.save(new Book(
                        "To Kill a Mockingbird",
                        "Harper Lee",
                        "9780061120084",
                        LocalDate.of(1960, 7, 11),
                        "A novel about racial injustice in a small Alabama town during the Great Depression.",
                        281
                ));

                bookRepository.save(new Book(
                        "1984",
                        "George Orwell",
                        "9780451524935",
                        LocalDate.of(1949, 6, 8),
                        "A dystopian novel set in a totalitarian society ruled by the Party, which has total control over every action and thought of the people.",
                        328
                ));

                bookRepository.save(new Book(
                        "The Great Gatsby",
                        "F. Scott Fitzgerald",
                        "9780743273565",
                        LocalDate.of(1925, 4, 10),
                        "A novel that explores themes of decadence, idealism, resistance to change, social upheaval, and excess.",
                        180
                ));

                bookRepository.save(new Book(
                        "The Hobbit",
                        "J.R.R. Tolkien",
                        "9780345339683",
                        LocalDate.of(1937, 9, 21),
                        "A fantasy novel about the adventures of the hobbit Bilbo Baggins.",
                        310
                ));

                bookRepository.save(new Book(
                        "Pride and Prejudice",
                        "Jane Austen",
                        "9780141439518",
                        LocalDate.of(1813, 1, 28),
                        "A romantic novel of manners that follows the character development of Elizabeth Bennet.",
                        432
                ));

                System.out.println("Sample books loaded into database!");
            }
        };
    }
}

Step 9: Create Application Profiles

  1. Create a file application-dev.properties in src/main/resources/:
# Development profile configuration
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true

# Show SQL
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Hibernate auto DDL
spring.jpa.hibernate.ddl-auto=update

# Logging
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
  1. Create a file application-prod.properties in src/main/resources/:
# Production profile configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/bookdb
spring.datasource.username=postgres
spring.datasource.password=password
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# Disable automatic schema creation to use migrations instead
spring.jpa.hibernate.ddl-auto=validate

# Disable showing SQL
spring.jpa.show-sql=false

# Disable H2 console
spring.h2.console.enabled=false

# Logging level
logging.level.root=INFO
logging.level.com.example.bookcatalog=WARN
  1. Update the main application.properties to use the dev profile by default:
# Active profile
spring.profiles.active=dev

# Common configuration
server.port=8081

# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0

# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings

# SpringDoc OpenAPI configuration
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method

Step 10: Add Database Migration with Flyway

  1. Add Flyway dependency to your pom.xml:
<!-- Flyway for database migrations -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
  1. Create a migration script for PostgreSQL. Create a file V1__Create_books_table.sql in src/main/resources/db/migration/:
CREATE TABLE IF NOT EXISTS books (
    id SERIAL PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    author VARCHAR(200),
    isbn VARCHAR(20) UNIQUE,
    publication_date DATE,
    description TEXT,
    page_count INTEGER
);

-- Insert initial data
INSERT INTO books (title, author, isbn, publication_date, description, page_count)
VALUES
    ('To Kill a Mockingbird', 'Harper Lee', '9780061120084', '1960-07-11', 'A novel about racial injustice in a small Alabama town during the Great Depression.', 281),
    ('1984', 'George Orwell', '9780451524935', '1949-06-08', 'A dystopian novel set in a totalitarian society ruled by the Party, which has total control over every action and thought of the people.', 328),
    ('The Great Gatsby', 'F. Scott Fitzgerald', '9780743273565', '1925-04-10', 'A novel that explores themes of decadence, idealism, resistance to change, social upheaval, and excess.', 180),
    ('The Hobbit', 'J.R.R. Tolkien', '9780345339683', '1937-09-21', 'A fantasy novel about the adventures of the hobbit Bilbo Baggins.', 310),
    ('Pride and Prejudice', 'Jane Austen', '9780141439518', '1813-01-28', 'A romantic novel of manners that follows the character development of Elizabeth Bennet.', 432);
  1. Add Flyway configuration to application-prod.properties:
# Flyway configuration
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration

Step 11: Create a Custom Health Indicator

package com.example.bookcatalog.health;

import com.example.bookcatalog.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class BookCatalogHealthIndicator implements HealthIndicator {

    private final BookRepository bookRepository;

    @Autowired
    public BookCatalogHealthIndicator(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public Health health() {
        try {
            long bookCount = bookRepository.count();
            return Health.up()
                    .withDetail("book_count", bookCount)
                    .withDetail("message", "Book catalog is operational")
                    .build();
        } catch (Exception e) {
            return Health.down()
                    .withDetail("error", e.getMessage())
                    .withDetail("message", "Book catalog is not operational")
                    .build();
        }
    }
}

Step 12: Create a Database Connection Configuration for PostgreSQL

  1. Create a PostgreSQL database configuration class:
package com.example.bookcatalog.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

import javax.sql.DataSource;

@Configuration
@Profile("prod")
public class PostgresConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }
}

Step 13: Test the Application with H2 Database

  1. Run the application with the dev profile:
mvn spring-boot:run -Dspring-boot.run.profiles=dev
  1. Access the H2 console at http://localhost:8081/h2-console and enter the database URL, username, and password from your application-dev.properties.

  2. Test the API using Swagger UI at http://localhost:8081/swagger-ui.html or with tools like Postman or curl.

Step 14: Set Up PostgreSQL Database

  1. Install PostgreSQL if you haven’t already.

  2. Create a new database called bookdb:

CREATE DATABASE bookdb;
  1. Run the application with the prod profile:
mvn spring-boot:run -Dspring-boot.run.profiles=prod
  1. Flyway will automatically run the migrations to create the tables and insert initial data.

  2. Test the API with the PostgreSQL database.

Step 15: Update Actuator Endpoint Configuration

  1. Add more detailed Actuator configuration in application.properties:
# Actuator endpoints
management.endpoints.web.exposure.include=health,info,metrics,mappings,flyway,beans,env
management.endpoint.health.show-details=always

# Application info
info.app.name=Book Catalog API
info.app.description=Spring Boot REST API for book catalog management
info.app.version=1.0.0
info.java.version=${java.version}
info.spring-boot.version=${spring-boot.version}

Step 16: Create Metrics

Add a metric counter to the BookController to track how many times each endpoint is accessed:

package com.example.bookcatalog.controller;

import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
// Other imports...

@RestController
@RequestMapping("/api/books")
@Tag(name = "Book Controller", description = "Operations pertaining to books in the catalog")
public class BookController {

    private final BookService bookService;
    private final MeterRegistry meterRegistry;

    @Autowired
    public BookController(BookService bookService, MeterRegistry meterRegistry) {
        this.bookService = bookService;
        this.meterRegistry = meterRegistry;
    }

    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        meterRegistry.counter("api.requests", "endpoint", "getAllBooks").increment();
        List<Book> books = bookService.findAllBooks();
        return ResponseEntity.ok(books);
    }

    // Update other methods similarly
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        meterRegistry.counter("api.requests", "endpoint", "getBookById").increment();
        Book book = bookService.findBookById(id);
        return ResponseEntity.ok(book);
    }

    // Continue for other methods...
}

Step 17: Final Testing

  1. Test all endpoints using Swagger UI or Postman.
  2. Verify that data is properly stored in the database.
  3. Check the Actuator endpoints to monitor application health and metrics.
  4. Test different profiles to ensure proper configuration.

Summary

In Module 4, we’ve successfully added data persistence to our Book Catalog application using Spring Data JPA. Here’s what we’ve accomplished:

  1. Configured Spring Data JPA: We set up Spring Data JPA with H2 database for development and PostgreSQL for production.

  2. Created JPA Entities: We transformed our Book model into a JPA entity with proper annotations and validations.

  3. Implemented Repositories: We created a BookRepository interface that extends JpaRepository, adding custom query methods for specific data access needs.

  4. Enhanced Service Layer: We updated our service layer to use the repository for database operations, adding transaction management.

  5. Added Data Validation: We implemented Bean Validation to ensure data integrity before persisting to the database.

  6. Implemented Pagination and Sorting: We added support for paginated results with sorting capabilities.

  7. Created Multiple Profiles: We set up development and production profiles with appropriate configurations.

  8. Added Database Migration: We implemented Flyway for database schema migration and initial data loading.

  9. Enhanced Exception Handling: We created a comprehensive exception handling system for database-related errors.

  10. Added Monitoring: We exposed useful metrics and health indicators through Spring Boot Actuator.


By Wahid Hamdi