JPA and Hibernate in Spring Boot
What JPA, Hibernate and Spring Data JPA each do, what the data-jpa starter configures for you, and the three settings to decide on day one.
@Entity class Book { @Id @GeneratedValue Long id; String title; }
interface BookRepository extends JpaRepository<Book, Long> {}
@Transactional void rename(Long id, String t) { repo.findById(id).orElseThrow().setTitle(t); }Three layers sit between your code and the database. Jakarta Persistence (JPA) is a specification: the jakarta.persistence annotations and the EntityManager API. Hibernate is the implementation that turns entity changes into SQL; Spring Boot 4.1 ships Hibernate 7. Spring Data JPA sits on top and writes repository classes for you from interfaces.
Add spring-boot-starter-data-jpa and a JDBC driver, and Boot creates a DataSource backed by HikariCP, an EntityManagerFactory, a JpaTransactionManager and a repository bean for every repository interface below your main class's package. Entities are found by the same package scan, so an entity in a sibling package that is not under the main class is silently ignored.
The persistence context
Inside a transaction, every entity you load is managed. Hibernate remembers its original state and, when the transaction commits, compares it with the current state and issues the UPDATE itself. This is dirty checking: changing a field on a loaded entity is enough, no save() call needed. The flip side is that a setter called by accident inside a transaction is written to the database too.
Loading the same id twice in one transaction returns the same object without a second query. Outside a transaction, entities are detached: changes are ignored and touching a lazy association throws LazyInitializationException.
Three settings to decide on day one
spring.jpa.hibernate.ddl-auto: Boot defaults it tocreate-dropfor an embedded database such as H2 when no schema tool like Flyway is present, otherwisenone. In production, let Flyway own the schema and usevalidateornone. Never runupdateagainst a shared database: it adds columns but never drops or renames them, and you cannot review what it did.spring.jpa.open-in-view: defaults totrue, which keeps theEntityManageropen for the whole web request so lazy loading works in controllers and during JSON serialisation. It hides N+1 queries and holds a connection while the response is written. Boot logs a warning at startup when you leave it unset. Set it tofalseand load what each endpoint needs in the service.- SQL logging:
spring.jpa.show-sqlprints to standard out with no logger. Preferlogging.level.org.hibernate.SQL=debug, which goes through your normal logging setup and can be switched per environment.
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=debugJPA is a good fit for aggregate-shaped data you load, change and save. For reporting queries and bulk updates, plain SQL with JdbcClient is often clearer; the two share the same DataSource and transactions, so you can mix them in one service.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class Book {
(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected Book() { }
Book(String title) { this.title = title; }
Long getId() { return id; }
String getTitle() { return title; }
void rename(String title) { this.title = title; }
}
interface BookRepository extends JpaRepository<Book, Long> { }
public class BookService {
private final BookRepository books;
public BookService(BookRepository books) {
this.books = books;
}
public Long create(String title) {
return books.save(new Book(title)).getId();
}
public void rename(Long id, String title) {
Book book = books.findById(id).orElseThrow();
book.rename(title); // no save(): dirty checking issues the UPDATE at commit
}
}When to use it
- Start a new orders API on PostgreSQL with Flyway owning the schema and Hibernate validating it.
- Turn off open-in-view in an existing service and fix the endpoints that relied on lazy loading.
- Log the SQL Hibernate generates while tracking down why an endpoint is slow.
More examples
The starter and a driver
The data-jpa starter brings Hibernate, Spring Data JPA, HikariCP and the transaction support. The driver is runtime scope because your code never imports it. Adding the Flyway starter makes Boot run migrations before Hibernate starts and keeps ddl-auto at none even on an embedded database. Current Flyway versions ship database support as separate modules, so PostgreSQL also needs flyway-database-postgresql or migrations fail at startup with an unsupported database error; Boot manages its version.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies>A production-minded configuration
validate makes startup fail when an entity does not match the migrated schema, which catches a missing column before the first request does. Anything under spring.jpa.properties is passed straight to Hibernate, here turning on JDBC batching for inserts. The bind logger shows parameter values, so keep it to development: it prints whatever data the query carries.
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=${DB_PASSWORD}
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=traceThe EntityManager underneath
Repositories are a convenience over this API. Boot injects a shared, transaction-aware EntityManager proxy, so injecting it into a singleton is safe. The second find returns the instance already in the persistence context, which is why two parts of one transaction always see the same object for the same row.
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Id;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Entity
class Product {
@Id
private Long id;
private int stock;
protected Product() { }
void take(int quantity) { this.stock -= quantity; }
}
@Component
public class StockAdjuster {
private final EntityManager em;
public StockAdjuster(EntityManager em) {
this.em = em;
}
@Transactional
public void take(Long productId, int quantity) {
Product first = em.find(Product.class, productId);
Product again = em.find(Product.class, productId); // same instance, no second SELECT
assert first == again;
first.take(quantity);
}
}
Discussion