Data Access with JPA

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

With H2 on the classpath and no spring.datasource.url, Boot configures an in-memory database. For PostgreSQL:

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=demo
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate

An entity

@Entity
public class Book {

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

    @Column(nullable = false)
    private String title;

    private String author;
    private int year;

    protected Book() {}                       // required by JPA

    public Book(String title, String author, int year) {  }

    // getters, and setters only where they make sense
}
Warning

Entities are mutable objects tied to a persistence context — not DTOs. Records cannot be entities. Map to a record before returning data from a controller.

Repositories

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByAuthor(String author);
    List<Book> findByYearGreaterThanOrderByYearDesc(int year);
    Optional<Book> findByTitleIgnoreCase(String title);
    boolean existsByTitle(String title);
    long countByAuthor(String author);
}

You write no implementation — Spring Data derives the query from the method name.

Inherited for free: save, saveAll, findById, findAll, delete, count, findAll(Pageable).

Own queries

@Query("select b from Book b where b.year between :from and :to")
List<Book> inRange(int from, int to);

@Query(value = "select * from book order by random() limit 1", nativeQuery = true)
Book random();

Projections keep result sets small:

public interface TitleOnly { String getTitle(); }

List<TitleOnly> findByAuthor(String author);

Paging and sorting

Page<Book> page = repo.findAll(PageRequest.of(0, 20, Sort.by("title")));
page.getContent(); page.getTotalElements(); page.getTotalPages();

Controllers can take a Pageable parameter directly: ?page=1&size=20&sort=title,asc.

Transactions

@Service
public class BookService {

    private final BookRepository repo;

    BookService(BookRepository repo) { this.repo = repo; }

    @Transactional
    public Book rename(long id, String title) {
        var book = repo.findById(id).orElseThrow(() -> new BookNotFoundException(id));
        book.setTitle(title);                 // dirty checking writes on commit
        return book;
    }

    @Transactional(readOnly = true)
    public List<Book> all() { return repo.findAll(); }
}

Rules: @Transactional belongs on the service, rolls back on unchecked exceptions, and only works when the call comes in from outside the bean (it is a proxy).

Schema management

SettingUse
spring.jpa.hibernate.ddl-auto=create-droptests, demos
…=updatelocal development, never production
…=validateproduction, with Flyway or Liquibase for migrations
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

Migrations live in src/main/resources/db/migration/V1__init.sql.

The N+1 trap

@Query("select b from Book b join fetch b.reviews where b.author = :author")
List<Book> withReviews(String author);

Turn on spring.jpa.show-sql=true once and count the statements.

★ Exercises

  1. Make Book an entity and add a BookRepository.
  2. Seed three books with an ApplicationRunner.
  3. Add findByAuthor and expose it as /api/books?author=.
  4. Add paging to the list endpoint.
  5. Write a @Transactional rename method and check that a thrown exception rolls it back.
  6. Enable show-sql and look at what a findAll really executes.