Article 21 left the product catalogue behind a ProductRepository interface with a single implementation, InMemoryProductRepository, and article 25 connected the application to H2 and PostgreSQL through a DataSource and wrote the SQL by hand with JdbcClient. This article hands the SQL to Hibernate. Product becomes a JPA entity, Spring Data JPA supplies the repository implementation, and the in-memory class is deleted.
The examples use Spring Boot 4.1.1 and Java 21, with an in-memory H2 database and PostgreSQL 18 running in Docker; each output names the database that produced it. The app runs on port 8126 instead of the default 8080, so that is the port in the curl commands.
![]()
The first half maps one entity and watches what Hibernate does with it; the second half puts it behind the Chapter 3 API. Most log excerpts were captured with logging.pattern.console=%logger{0}: %msg%n, which prints only the logger's short name and the message; excerpts with timestamps use Boot's default pattern.
JPA, Hibernate and Spring Data JPA: specification, implementation, repository
Three names are used almost interchangeably, and they are three different things.
- Jakarta Persistence 3.2, still called JPA, is a specification. It defines the
jakarta.persistenceannotations such as@Entity,@Idand@Column, and theEntityManagerinterface withpersist,find,mergeandremove. Thejakarta.persistence-api-3.2.0.jarcontains interfaces and annotations only. - Hibernate ORM 7.4.5 is an implementation of that specification. It reads the annotations, generates SQL for the database it detects, keeps track of the objects it has loaded, and runs the statements through JDBC. Its
SessionImplis the object behind everyEntityManagerin this article. - Spring Data JPA 4.1.1 is a repository abstraction on top of
EntityManager. You declare an interface; at startup Spring Data creates a proxy that implements it, backed by its own classSimpleJpaRepository.

Application code talks to the top layer and, for the persistence context demonstration later, to EntityManager. Nothing in this article imports a Hibernate class.
What spring-boot-starter-data-jpa brings
The project was generated with the dependencies web,validation,data-jpa,h2,postgresql:
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Initializr added spring-boot-h2console on its own because H2 and the web starter were both selected, and every starter comes with its -test twin. What the JPA starter pulls in:
./gradlew dependencies --configuration runtimeClasspathTrimmed to the persistence lines:
+--- org.springframework.boot:spring-boot-starter-data-jpa -> 4.1.1
| +--- org.springframework.boot:spring-boot-starter-jdbc:4.1.1
| | +--- org.springframework.boot:spring-boot-jdbc:4.1.1
| | \--- com.zaxxer:HikariCP:7.0.2
| +--- org.springframework.boot:spring-boot-data-jpa:4.1.1
| | +--- org.springframework.boot:spring-boot-data-commons:4.1.1
| | | \--- org.springframework.data:spring-data-commons:4.1.1
| | +--- org.springframework.boot:spring-boot-hibernate:4.1.1
| | | +--- org.springframework.boot:spring-boot-jpa:4.1.1
| | | | +--- jakarta.persistence:jakarta.persistence-api:3.2.0
| | | | \--- org.springframework:spring-orm:7.0.9
| | | +--- org.hibernate.orm:hibernate-core:7.4.5.Final
| | | \--- org.springframework:spring-orm:7.0.9 (*)
| | +--- org.springframework.data:spring-data-jpa:4.1.1
| | \--- org.springframework:spring-aspects:7.0.9
| \--- org.springframework.boot:spring-boot-jdbc:4.1.1 (*)spring-boot-starter-jdbcis theDataSourceand HikariCP pool from article 25. JPA sits on top of it; it does not replace it.spring-boot-data-jpa,spring-boot-hibernateandspring-boot-jpaare Boot 4's JPA auto-configuration, split into modules where Boot 3 had a singlespring-boot-autoconfigurejar:spring-boot-hibernatecontainsHibernateJpaAutoConfiguration, which builds theEntityManagerFactorywith Hibernate,spring-boot-data-jpacontainsDataJpaRepositoriesAutoConfiguration, which creates the repositories, andspring-boot-jpaholds the shared JPA configuration and thespring.jpa.*properties.jakarta.persistence-api3.2.0,hibernate-core7.4.5.Final andspring-data-jpa4.1.1 are the specification, the implementation and the repository layer from the picture.
Mapping the first entity: @Entity, @Id, @GeneratedValue and @Column
Article 21's Product was a record with with methods. An entity is a class Hibernate can create empty, fill from a row and change in place, so it becomes an ordinary class. It keeps the article 21 fields, gains category as a plain column, and gets a status so that @Enumerated has something to map:
package com.example.demo.product;
public enum ProductStatus {
ACTIVE, OUT_OF_STOCK, DISCONTINUED
}package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, length = 40, unique = true)
private String sku;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private int stock;
@Column(nullable = false, length = 60)
private String category;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProductStatus status = ProductStatus.ACTIVE;
protected Product() {
}
public Product(String name, String sku, BigDecimal price, int stock, String category) {
this.name = name;
this.sku = sku;
this.price = price;
this.stock = stock;
this.category = category;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSku() { return sku; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public ProductStatus getStatus() { return status; }
public void setStatus(ProductStatus status) { this.status = status; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Product.class.hashCode();
}
@Override
public String toString() {
return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock + ", status=" + status + "]";
}
}@Entitytells Hibernate to manage the class. Every annotation comes fromjakarta.persistence, not from Hibernate.@Table(name = "products")gives the table an explicit name,products.@Idmarks the primary key, and@GeneratedValue(strategy = GenerationType.IDENTITY)leaves its value to the database. The strategies get their own section.@Columndescribes the column:nullable = falsebecomesnot null,lengththe size of avarchar,unique = truea unique constraint,precisionandscalethe size of anumeric. These attributes shape the DDL Hibernate generates; they do not validate anything in Java.private int stockhas no annotation. Every field of an entity is persistent by default, and a primitive cannot holdnull.@Enumerated(EnumType.STRING)stores the constant's name. The default is the position, which has a trap of its own below.protected Product()is for Hibernate, which creates the object before it fills the fields. The public constructor is for your code. There is nosetId, because the database assigns it, and nosetSku, because a SKU does not change.equals,hashCodeandtoStringare explained in the section on entity equality.toStringdeliberately prints only a few fields.

The DDL Hibernate generated on H2 and PostgreSQL
With no spring.datasource.url configured, Boot creates an in-memory H2 database with a random name. Started with logging.level.org.hibernate.SQL=DEBUG, which the section on SQL logging explains, the startup log on H2 contains:
2026-09-13T16:10:42.204+07:00 INFO 9316 --- [demo] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:d3b1b5b4-d92e-4b0b-a61e-62b69972d717 user=SA
2026-09-13T16:10:42.812+07:00 DEBUG 9316 --- [demo] [ main] org.hibernate.SQL : drop table if exists products cascade
2026-09-13T16:10:42.814+07:00 DEBUG 9316 --- [demo] [ main] org.hibernate.SQL : create table products (price numeric(10,2) not null, stock integer not null, id bigint generated by default as identity, sku varchar(40) not null unique, category varchar(60) not null, name varchar(120) not null, status enum ('ACTIVE','DISCONTINUED','OUT_OF_STOCK') not null, primary key (id))For PostgreSQL, a postgres profile holds the three connection properties article 25 introduced, pointed at the PostgreSQL 18 container:
spring.datasource.url=jdbc:postgresql://localhost:55426/catalog
spring.datasource.username=catalog
spring.datasource.password=catalogOn PostgreSQL Boot generates no DDL at all by default (the ddl-auto section shows why), so this run added spring.jpa.hibernate.ddl-auto=create:
2026-09-13T16:12:48.451+07:00 DEBUG 9835 --- [demo] [ main] org.hibernate.SQL : set client_min_messages = WARNING
2026-09-13T16:12:48.452+07:00 DEBUG 9835 --- [demo] [ main] org.hibernate.SQL : drop table if exists products cascade
2026-09-13T16:12:48.454+07:00 DEBUG 9835 --- [demo] [ main] org.hibernate.SQL : create table products (price numeric(10,2) not null, stock integer not null, id bigint generated by default as identity, status varchar(20) not null check ((status in ('ACTIVE','OUT_OF_STOCK','DISCONTINUED'))), sku varchar(40) not null unique, category varchar(60) not null, name varchar(120) not null, primary key (id))What PostgreSQL made of it:
docker exec sb-a26-pg psql -U catalog -d catalog -c '\d products' Table "public.products"
Column | Type | Collation | Nullable | Default
----------+------------------------+-----------+----------+----------------------------------
price | numeric(10,2) | | not null |
stock | integer | | not null |
id | bigint | | not null | generated by default as identity
status | character varying(20) | | not null |
sku | character varying(40) | | not null |
category | character varying(60) | | not null |
name | character varying(120) | | not null |
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
"products_sku_key" UNIQUE CONSTRAINT, btree (sku)
Check constraints:
"products_status_check" CHECK (status::text = ANY (ARRAY['ACTIVE'::character varying, 'OUT_OF_STOCK'::character varying, 'DISCONTINUED'::character varying]::text[]))- The column order is Hibernate's, not yours: price, stock, id, then the rest. It does not affect how the entity maps.
stock integer not nullcame from the primitiveintalone.unique = truebecame an inlineunique, which PostgreSQL namedproducts_sku_key.- The enum column differs by database. H2 got its native
enumtype, with the values sorted alphabetically; PostgreSQL gotvarchar(20)plus a check constraint listing the constants in declaration order. IDENTITYbecamegenerated by default as identityon both.
The EnumType.ORDINAL trap
@Enumerated without an argument means EnumType.ORDINAL: the database stores the constant's position. To see what that does, the status mapping is changed for a moment:
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@Enumerated
@Column(nullable = false)
private ProductStatus status = ProductStatus.ACTIVE;The DDL on H2, then on PostgreSQL:
SQL: create table products (price numeric(10,2) not null, status tinyint not null check ((status between 0 and 2)), stock integer not null, id bigint generated by default as identity, sku varchar(40) not null unique, category varchar(60) not null, name varchar(120) not null, primary key (id))SQL: create table products (price numeric(10,2) not null, status smallint not null check ((status between 0 and 2)), stock integer not null, id bigint generated by default as identity, sku varchar(40) not null unique, category varchar(60) not null, name varchar(120) not null, primary key (id))The runs that exercise the repository without HTTP use small CommandLineRunner classes in a lab package, each behind its own profile, started with --spring.main.web-application-type=none so the process ends when the runner returns. This one saves three products and is reused by the id strategy section:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.util.List;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import com.example.demo.product.ProductStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("ids")
class IdTour implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(IdTour.class);
private final ProductRepository repository;
private final String run;
private final String mode;
IdTour(ProductRepository repository, @Value("${tour.run:1}") String run, @Value("${tour.mode:ids}") String mode) {
this.repository = repository;
this.run = run;
this.mode = mode;
}
@Override
public void run(String... args) {
if (mode.equals("read")) {
repository.findAll().forEach(p -> log.info("{}", p));
return;
}
Product keyboard = new Product("Mechanical keyboard", "KB-0" + run, new BigDecimal("89.90"), 25, "Keyboards");
Product mouse = new Product("Wireless mouse", "MS-0" + run, new BigDecimal("24.50"), 0, "Mice");
Product hub = new Product("USB-C hub", "HUB-0" + run, new BigDecimal("39.00"), 10, "Accessories");
if (mode.equals("statuses")) {
mouse.setStatus(ProductStatus.OUT_OF_STOCK);
hub.setStatus(ProductStatus.DISCONTINUED);
}
log.info("-- saveAll(three new products)");
List<Product> saved = repository.saveAll(List.of(keyboard, mouse, hub));
log.info("ids={}", saved.stream().map(Product::getId).toList());
if (mode.equals("statuses")) {
repository.findAll().forEach(p -> log.info("{}", p));
}
}
}On PostgreSQL, with ddl-auto=create and --tour.mode=statuses:
IdTour: -- saveAll(three new products)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
IdTour: ids=[1, 2, 3]
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0
IdTour: Product[id=1, sku=KB-01, price=89.90, stock=25, status=ACTIVE]
IdTour: Product[id=2, sku=MS-01, price=24.50, stock=0, status=OUT_OF_STOCK]
IdTour: Product[id=3, sku=HUB-01, price=39.00, stock=10, status=DISCONTINUED]docker exec sb-a26-pg psql -U catalog -d catalog -c 'select id, sku, status from products order by id' id | sku | status
----+--------+--------
1 | KB-01 | 0
2 | MS-01 | 1
3 | HUB-01 | 2
(3 rows)Now someone sorts the constants alphabetically, a change that looks harmless in a code review:
public enum ProductStatus {
ACTIVE, OUT_OF_STOCK, DISCONTINUED
ACTIVE, DISCONTINUED, OUT_OF_STOCK
}The rebuilt application reads the same rows, with --tour.mode=read and no ddl-auto, so the table is left alone:
IdTour: Product[id=1, sku=KB-01, price=89.90, stock=25, status=ACTIVE]
IdTour: Product[id=2, sku=MS-01, price=24.50, stock=0, status=DISCONTINUED]
IdTour: Product[id=3, sku=HUB-01, price=39.00, stock=10, status=OUT_OF_STOCK]No exception and no warning. The mouse, out of stock a minute ago, is now discontinued, and the hub is back in the catalogue as merely out of stock. The rows still hold 1 and 2, which still satisfy check (status between 0 and 2); only their meaning changed. With EnumType.STRING the rows hold 'OUT_OF_STOCK' and 'DISCONTINUED', so the order of the constants stops mattering and their names become part of the schema, as the check constraint already shows. Use STRING.
An entity without a no-arg constructor, a record, or a final class
Three variations of Product that come up in every Java 21 code review, each built and run on H2 with IdTour.
No no-arg constructor. Deleting the protected constructor:
protected Product() {
} The application started, saved three products, and failed on the first read (the condition report lines are trimmed):
2026-09-13T16:16:37.975+07:00 INFO 10669 --- [demo] [ main] org.hibernate.orm.core : HHH000182: No default (no-argument) constructor for class [com.example.demo.product.Product] (class must be instantiated by Interceptor)
2026-09-13T16:16:38.208+07:00 INFO 10669 --- [demo] [ main] com.example.demo.lab.IdTour : -- saveAll(three new products)
2026-09-13T16:16:38.242+07:00 DEBUG 10669 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:16:38.261+07:00 DEBUG 10669 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:16:38.262+07:00 DEBUG 10669 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:16:38.267+07:00 INFO 10669 --- [demo] [ main] com.example.demo.lab.IdTour : ids=[1, 2, 3]
2026-09-13T16:16:38.329+07:00 DEBUG 10669 --- [demo] [ main] org.hibernate.SQL : select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0
2026-09-13T16:16:38.340+07:00 ERROR 10669 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.orm.jpa.JpaSystemException: No default constructor for entity 'com.example.demo.product.Product'
...
Caused by: org.hibernate.InstantiationException: No default constructor for entity 'com.example.demo.product.Product'
at org.hibernate.metamodel.internal.EntityInstantiatorPojoStandard.instantiate(EntityInstantiatorPojoStandard.java:94) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]Hibernate 7.4 does not refuse the mapping. It logs HHH000182 at INFO level, easy to miss among the startup lines, and the class works for as long as your code creates the objects. The first query breaks, because Hibernate has to create an empty Product before it can copy a row into it. The constructor can be protected, as above, so that the rest of the code cannot create an empty product by accident.
A record annotated with @Entity. Java 21 makes this tempting:
package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public record Product(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id,
@Column(nullable = false, length = 120) String name,
@Column(nullable = false, length = 40, unique = true) String sku,
@Column(nullable = false, precision = 10, scale = 2) BigDecimal price,
int stock,
@Column(nullable = false, length = 60) String category,
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) ProductStatus status) {
}It compiles, the application starts, and the table is created. A runner that saved one record, new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25, "Keyboards", ProductStatus.ACTIVE), got this far:
2026-09-13T16:16:42.788+07:00 INFO 10721 --- [demo] [ main] org.hibernate.orm.core : HHH000182: No default (no-argument) constructor for class [com.example.demo.product.Product] (class must be instantiated by Interceptor)
2026-09-13T16:16:43.121+07:00 DEBUG 10721 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:16:43.150+07:00 ERROR 10721 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.orm.jpa.JpaSystemException: Could not set value of type [java.lang.Long]: 'com.example.demo.product.Product.id' (setter)
...
Caused by: org.hibernate.PropertyAccessException: Could not set value of type [java.lang.Long]: 'com.example.demo.product.Product.id' (setter)
...
at org.hibernate.action.internal.EntityIdentityInsertAction.execute(EntityIdentityInsertAction.java:119) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
...
Caused by: java.lang.IllegalAccessException: Can not set final java.lang.Long field com.example.demo.product.Product.id to java.lang.LongThe INSERT went out, and Hibernate then failed to write the generated id into the record's final field. A record has no no-arg constructor either, so reads would fail like the previous variant. An entity is mutable by design: Hibernate sets its id, fills it from rows and watches its fields change. Records stay where article 18 put them, as request and response DTOs.
A final class. public final class Product started without any warning at INFO level, and IdTour saved and read the three products exactly as before. The difference shows up with getReferenceById, which returns a reference without loading the row. A runner that calls it inside a transaction:
package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
@Component
@Profile("reference")
class ReferenceTour implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ReferenceTour.class);
private final ProductRepository repository;
private final TransactionTemplate tx;
ReferenceTour(ProductRepository repository, PlatformTransactionManager transactionManager) {
this.repository = repository;
this.tx = new TransactionTemplate(transactionManager);
}
@Override
public void run(String... args) {
Product saved = repository.save(new Product("Webcam", "REF-01", new BigDecimal("59.00"), 4, "Cameras"));
tx.executeWithoutResult(status -> {
log.info("-- getReferenceById({})", saved.getId());
Product reference = repository.getReferenceById(saved.getId());
log.info("class={}", reference.getClass().getName());
log.info("sku={}", reference.getSku());
});
}
}Against a non-final Product:
ReferenceTour: -- getReferenceById(1)
ReferenceTour: class=com.example.demo.product.Product$HibernateProxy
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
ReferenceTour: sku=REF-01Against the final one:
2026-09-13T16:16:40.778+07:00 INFO 10693 --- [demo] [ main] com.example.demo.lab.ReferenceTour : -- getReferenceById(4)
2026-09-13T16:16:40.780+07:00 DEBUG 10693 --- [demo] [ main] org.hibernate.SQL : select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
2026-09-13T16:16:40.780+07:00 INFO 10693 --- [demo] [ main] com.example.demo.lab.ReferenceTour : class=com.example.demo.product.Product
2026-09-13T16:16:40.780+07:00 INFO 10693 --- [demo] [ main] com.example.demo.lab.ReferenceTour : sku=REF-01For the normal class Hibernate returned Product$HibernateProxy, a generated subclass, and sent the SELECT only when getSku() needed data. A final class cannot be subclassed, so Hibernate loaded the row on the spot and returned a plain Product. Nothing failed here, but lazy loading, which relationships depend on in article 28, works through such proxies. Keep entities non-final.
Choosing an id strategy: IDENTITY, SEQUENCE, AUTO or UUID
@GeneratedValue has four strategies. For a Long id the choice is between IDENTITY, SEQUENCE and AUTO; UUID needs a UUID field. Each variant below ran IdTour on H2 and twice on PostgreSQL: once with ddl-auto=create, then again without it and with --tour.run=2, to see which ids a restarted application hands out.
IDENTITY: the database assigns the id during the INSERT
This is the mapping above. On H2 the INSERT asks for the column's default:
IdTour: -- saveAll(three new products)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
IdTour: ids=[1, 2, 3]On PostgreSQL the id column is left out of the statement, and the second run continued where the first stopped:
IdTour: -- saveAll(three new products)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
IdTour: ids=[1, 2, 3]IdTour: -- saveAll(three new products)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
IdTour: ids=[4, 5, 6]Only the database knows the next value, so Hibernate has to send each INSERT the moment an entity is saved to learn its id. The CRUD section shows that ordering in the log.
SEQUENCE and AUTO: products_seq, 50 ids per call
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;The DDL on H2:
SQL: drop table if exists products cascade
SQL: drop sequence if exists products_seq
SQL: create sequence products_seq start with 1 increment by 50
SQL: create table products (price numeric(10,2) not null, stock integer not null, id bigint not null, sku varchar(40) not null unique, category varchar(60) not null, name varchar(120) not null, status enum ('ACTIVE','DISCONTINUED','OUT_OF_STOCK') not null, primary key (id))The id column lost its generated by default as identity, and a sequence named after the table appeared, with increment by 50. On H2 the log also contained a create global temporary table HTE_products(...) statement before these; that table is Hibernate's own, not part of the mapping. PostgreSQL got the same sequence, create sequence products_seq start with 1 increment by 50. The first run there:
IdTour: -- saveAll(three new products)
SQL: select nextval('products_seq')
SQL: select nextval('products_seq')
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
IdTour: ids=[1, 2, 3]The restart:
IdTour: -- saveAll(three new products)
SQL: select nextval('products_seq')
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
IdTour: ids=[52, 53, 54]docker exec sb-a26-pg psql -U catalog -d catalog -c "select sequencename, start_value, increment_by, last_value from pg_sequences" sequencename | start_value | increment_by | last_value
--------------+-------------+--------------+------------
products_seq | 1 | 50 | 101
(1 row)With increment by 50, one nextval call covers a block of ids that Hibernate hands out from memory. On the fresh sequence the first call returned 1 and Hibernate called a second time; the three products took 1, 2 and 3. The restarted application knew nothing about the unused part of its old block, called nextval once, got 101, and numbered from 52. The gap is by design, and nothing should depend on ids being consecutive. The INSERTs now carry the id as a bound parameter, and they were sent after all three ids were known rather than one by one.
Then @GeneratedValue with no strategy, which means AUTO:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@GeneratedValue
private Long id;On both databases AUTO produced exactly the output of SEQUENCE: the same products_seq with increment by 50, the same nextval calls, ids 1, 2 and 3, then 52, 53 and 54 after the restart, and a single row in pg_sequences. For a Long id, Hibernate 7.4 resolves AUTO to a sequence per entity, not to IDENTITY and not to one sequence shared by every table.
UUID ids
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id; The repository's second type argument becomes UUID as well, JpaRepository<Product, UUID>, and getId() returns a UUID. The column on H2 and on PostgreSQL is id uuid not null, with no sequence. With bind logging on, the first INSERT on H2:
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (3:NUMERIC) <- [89.90]
bind: binding parameter (4:VARCHAR) <- [KB-01]
bind: binding parameter (5:ENUM) <- [ACTIVE]
bind: binding parameter (6:INTEGER) <- [25]
bind: binding parameter (7:UUID) <- [c74d4195-3340-49d3-af5a-e2f89d785d9f]The id is generated in Java before the INSERT, with no call to the database. All six ids the two runs produced, such as c74d4195-3340-49d3-af5a-e2f89d785d9f and ffaf59aa-c2db-45e4-aa45-ac1161cceaee, have a 4 at the start of the third group: random, version 4 UUIDs. A UUID id can be created anywhere and reveals nothing about how many rows exist; in exchange it is longer to type in a URL than /api/products/42.
| Strategy | id column on H2 and PostgreSQL | Where the id comes from | Ids after a restart (PostgreSQL) |
|---|---|---|---|
IDENTITY | bigint generated by default as identity | the INSERT itself | 1, 2, 3, then 4, 5, 6 |
SEQUENCE | bigint not null + products_seq, increment by 50 | nextval('products_seq'), one call per block | 1, 2, 3, then 52, 53, 54 |
AUTO | identical to SEQUENCE | identical to SEQUENCE | 1, 2, 3, then 52, 53, 54 |
UUID | uuid not null | generated in Java | random |
The series keeps IDENTITY: the table definition stays self-contained, which suits the migration scripts article 31 writes by hand, and the ids are easy to read in URLs and logs.
Seeing the SQL Hibernate sends
Everything above relied on reading the statements Hibernate sent. There are two switches for that, and one of them is better.
spring.jpa.show-sql vs logging.level.org.hibernate.SQL
spring.jpa.show-sql=true, whose default in the 4.1.1 metadata is false, produced this during the CRUD runner shown later:
2026-09-13T16:10:47.997+07:00 INFO 9333 --- [demo] [ main] com.example.demo.lab.CrudTour : -- save(new Product)
Hibernate: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:10:48.049+07:00 INFO 9333 --- [demo] [ main] com.example.demo.lab.CrudTour : id=1, same instance=true, still found in HashSet=trueThe Hibernate: line has no timestamp, level, thread or logger name. Hibernate writes it straight to standard output, around Logback, so no logging configuration applies to it: it cannot be sent to a log file, filtered, or switched on for one profile with a log level. logging.level.org.hibernate.SQL=DEBUG, used for every DDL excerpt so far, sends the same text through the org.hibernate.SQL logger:
2026-09-13T16:10:49.892+07:00 DEBUG 9345 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)Use the logger and leave show-sql off.
Bind parameter values with org.hibernate.orm.jdbc.bind
The statements show ? placeholders. The values bound to them are logged at TRACE level by the org.hibernate.orm.jdbc.bind category in Hibernate 7.4:
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE2026-09-13T16:10:49.863+07:00 INFO 9345 --- [demo] [ main] com.example.demo.lab.CrudTour : -- save(new Product)
2026-09-13T16:10:49.892+07:00 DEBUG 9345 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
2026-09-13T16:10:49.894+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [Keyboards]
2026-09-13T16:10:49.894+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [Mechanical keyboard]
2026-09-13T16:10:49.894+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (3:NUMERIC) <- [89.90]
2026-09-13T16:10:49.894+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [KB-01]
2026-09-13T16:10:49.895+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (5:ENUM) <- [ACTIVE]
2026-09-13T16:10:49.895+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (6:INTEGER) <- [25]And for findById(1):
2026-09-13T16:10:49.929+07:00 DEBUG 9345 --- [demo] [ main] org.hibernate.SQL : select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
2026-09-13T16:10:49.929+07:00 TRACE 9345 --- [demo] [ main] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]Each line gives the position, the JDBC type and the value. On H2, status is bound as ENUM, the native type its column got. The id is not in the INSERT's list, because IDENTITY leaves it to the default in the statement. Bind logging writes every value the application stores, customer data included, so it belongs in local debugging, not in production logs.
Formatting the SQL with hibernate.format_sql
Any Hibernate setting can be passed with the spring.jpa.properties. prefix. hibernate.format_sql breaks each statement over several lines:
spring.jpa.properties.hibernate.format_sql=true2026-09-13T16:10:51.788+07:00 INFO 9353 --- [demo] [ main] com.example.demo.lab.CrudTour : -- save(new Product)
2026-09-13T16:10:51.820+07:00 DEBUG 9353 --- [demo] [ main] org.hibernate.SQL :
insert
into
products
(category, name, price, sku, status, stock, id)
values
(?, ?, ?, ?, ?, ?, default)It helps with long queries and makes the log harder to search with grep. The rest of this article leaves it off.
spring.jpa.hibernate.ddl-auto: what Spring Boot applies on H2 and PostgreSQL
On H2 the table appeared without any setting; on PostgreSQL it did not. spring.jpa.hibernate.ddl-auto has no static default in Boot's property metadata, because Boot decides at startup. This runner prints the value Hibernate actually received, together with the database it is talking to; its first lines matter in the next section:
package com.example.demo.lab;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Connection;
import java.util.Arrays;
import javax.sql.DataSource;
import com.example.demo.product.ProductRepository;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("inspect")
class RepositoryInspector implements CommandLineRunner {
private final ProductRepository repository;
private final EntityManagerFactory entityManagerFactory;
private final DataSource dataSource;
RepositoryInspector(ProductRepository repository, EntityManagerFactory entityManagerFactory, DataSource dataSource) {
this.repository = repository;
this.entityManagerFactory = entityManagerFactory;
this.dataSource = dataSource;
}
@Override
public void run(String... args) throws Exception {
System.out.println("bean class : " + repository.getClass().getName());
System.out.println("JDK proxy : " + AopUtils.isJdkDynamicProxy(repository));
System.out.println("target class : " + AopProxyUtils.ultimateTargetClass(repository).getName());
System.out.println("proxy implements: " + Arrays.stream(repository.getClass().getInterfaces()).map(Class::getSimpleName).toList());
printHierarchy(ProductRepository.class, 0);
System.out.println("hibernate.hbm2ddl.auto = " + entityManagerFactory.getProperties().get("hibernate.hbm2ddl.auto"));
try (Connection connection = dataSource.getConnection()) {
var meta = connection.getMetaData();
System.out.println("database : " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion() + " at " + meta.getURL());
}
}
private static void printHierarchy(Type type, int depth) {
Class<?> raw = type instanceof ParameterizedType p ? (Class<?>) p.getRawType() : (Class<?>) type;
System.out.println(" ".repeat(depth) + type.getTypeName().replaceAll("[a-z][a-z0-9_]*\\.", ""));
for (Type parent : raw.getGenericInterfaces()) {
printHierarchy(parent, depth + 1);
}
}
}On H2, with nothing configured:
hibernate.hbm2ddl.auto = create-drop
database : H2 2.4.240 (2025-09-22) at jdbc:h2:mem:d3b1b5b4-d92e-4b0b-a61e-62b69972d717On PostgreSQL, with only the connection settings:
hibernate.hbm2ddl.auto = null
database : PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) at jdbc:postgresql://localhost:55426/catalognull means Boot passed nothing and Hibernate did nothing to the schema. The decision is made in HibernateDefaultDdlAutoProvider, in spring-boot-hibernate-4.1.1.jar:
javap -c -p -classpath spring-boot-hibernate-4.1.1.jar org.springframework.boot.hibernate.autoconfigure.HibernateDefaultDdlAutoProviderThe method that matters:
java.lang.String getDefaultDdlAuto(javax.sql.DataSource);
Code:
0: aload_1
1: invokestatic #13 // Method org/springframework/boot/jdbc/EmbeddedDatabaseConnection.isEmbedded:(Ljavax/sql/DataSource;)Z
4: ifne 10
7: ldc #19 // String none
9: areturn
10: aload_0
11: aload_1
12: invokevirtual #21 // Method getSchemaManagement:(Ljavax/sql/DataSource;)Lorg/springframework/boot/jdbc/SchemaManagement;
15: astore_2
16: getstatic #25 // Field org/springframework/boot/jdbc/SchemaManagement.MANAGED:Lorg/springframework/boot/jdbc/SchemaManagement;
19: aload_2
20: invokevirtual #31 // Method org/springframework/boot/jdbc/SchemaManagement.equals:(Ljava/lang/Object;)Z
23: ifeq 29
26: ldc #19 // String none
28: areturn
29: ldc #35 // String create-drop
31: areturnRead as Java: a database that is not embedded gets none; an embedded one gets none as well when a schema manager such as Flyway or Liquibase already manages it; any other embedded database gets create-drop. The in-memory H2 database counts as embedded, and PostgreSQL does not. Without a table, the first INSERT on PostgreSQL failed:
2026-09-13T16:12:46.938+07:00 DEBUG 9828 --- [demo] [ main] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
2026-09-13T16:12:46.945+07:00 WARN 9828 --- [demo] [ main] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 0, SQLState: 42P01
2026-09-13T16:12:46.945+07:00 WARN 9828 --- [demo] [ main] org.hibernate.orm.jdbc.error : ERROR: relation "products" does not exist
Position: 13
...
org.springframework.dao.InvalidDataAccessResourceUsageException: could not execute statement [ERROR: relation "products" does not exist
Position: 13] [insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)]; SQL [insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)]
...
Caused by: org.hibernate.exception.SQLGrammarException: could not execute statement [ERROR: relation "products" does not exist
Position: 13] [insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)]
...
Caused by: org.postgresql.util.PSQLException: ERROR: relation "products" does not exist
Position: 13The chain is the same three levels every database error in this article has: a Spring DataAccessException subclass, the Hibernate exception it wraps, and the JDBC driver's exception at the bottom.
create, create-drop, update, validate and none
| Value | At startup | At shutdown | Observed in these runs |
|---|---|---|---|
none | nothing | nothing | the default on PostgreSQL; relation "products" does not exist on the first INSERT |
create | drops the tables and sequences it maps, then creates them | nothing | drop table if exists products cascade, then create table products (...); every restart starts empty |
create-drop | like create | drops them again | the default on in-memory H2; the log ends with drop table if exists products cascade |
update | creates what is missing, adds missing columns | nothing | see below |
validate | compares the mapping with the schema, changes nothing | nothing | startup fails when something is missing |
The end of an H2 run with create-drop:
2026-09-13T16:10:42.985+07:00 INFO 9316 --- [demo] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-09-13T16:10:42.985+07:00 DEBUG 9316 --- [demo] [ionShutdownHook] org.hibernate.SQL : drop table if exists products cascadeupdate on an empty PostgreSQL database did not produce the DDL that create did:
2026-09-13T16:12:59.865+07:00 DEBUG 9877 --- [demo] [ main] org.hibernate.SQL : create table products (id bigint generated by default as identity, category varchar(60) not null, name varchar(120) not null, price numeric(10,2) not null, sku varchar(40) not null, status varchar(20) not null check ((status in ('ACTIVE','OUT_OF_STOCK','DISCONTINUED'))), stock integer not null, primary key (id))
2026-09-13T16:12:59.870+07:00 DEBUG 9877 --- [demo] [ main] org.hibernate.SQL : alter table if exists products drop constraint if exists UKfhmd06dsmj6k0n90swsh8ie9g
2026-09-13T16:12:59.872+07:00 WARN 9877 --- [demo] [ main] org.hibernate.orm.jdbc.warn : HHH000247: ErrorCode: 0, SQLState: 00000
2026-09-13T16:12:59.872+07:00 WARN 9877 --- [demo] [ main] org.hibernate.orm.jdbc.warn : constraint "ukfhmd06dsmj6k0n90swsh8ie9g" of relation "products" does not exist, skipping
2026-09-13T16:12:59.872+07:00 DEBUG 9877 --- [demo] [ main] org.hibernate.SQL : alter table if exists products add constraint UKfhmd06dsmj6k0n90swsh8ie9g unique (sku)The columns come in a different order, and the unique constraint arrives as a separate alter table with a generated name, UKfhmd06dsmj6k0n90swsh8ie9g, which PostgreSQL stores lower-cased. The WARN is PostgreSQL's notice that there was nothing to drop. A second start with update against that table logged no DDL at all.
For the other two values, the entity temporarily gained a field, @Column(length = 80) private String brand;, and ran against a table created by create from the mapping without it. validate refused to start:
2026-09-13T16:16:48.073+07:00 ERROR 10786 --- [demo] [ main] j.LocalContainerEntityManagerFactoryBean : Failed to initialize JPA EntityManagerFactory: Unable to build Hibernate SessionFactory [persistence unit: default] ; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing column [brand] in table [products]update added the column and started:
2026-09-13T16:16:50.356+07:00 DEBUG 10818 --- [demo] [ main] org.hibernate.SQL : alter table if exists products add column brand varchar(80)That convenience is why update is tempting and why it is not a migration tool. It keeps no record of what it changed, nobody reviews the DDL before it runs, and the table it builds from nothing is not the table create builds, as the two listings show. Leave in-memory H2 on its default; on PostgreSQL this chapter uses update for its experiments. Article 31 replaces ddl-auto with Flyway migrations.
JpaRepository: CRUD without an implementation class
The interface hierarchy and the proxy behind it
This is the whole repository:
package com.example.demo.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}The two type arguments are the entity and the type of its @Id. No class implements the interface, and the build output of the finished project confirms that nothing was generated at compile time either:
find build/classes/java/main -name '*.class' | sort | sed 's|build/classes/java/main/||'Trimmed to the product package:
com/example/demo/product/CreateProductRequest.class
com/example/demo/product/Product.class
com/example/demo/product/ProductController.class
com/example/demo/product/ProductMapper.class
com/example/demo/product/ProductNotFoundException.class
com/example/demo/product/ProductRepository.class
com/example/demo/product/ProductResponse.class
com/example/demo/product/ProductService.class
com/example/demo/product/ProductStatus.classThe implementation is created at startup. RepositoryInspector from the previous section printed, on H2:
bean class : jdk.proxy2.$Proxy116
JDK proxy : true
target class : org.springframework.data.jpa.repository.support.SimpleJpaRepository
proxy implements: [ProductRepository, Repository, TransactionalProxy, Advised, DecoratingProxy]
ProductRepository
JpaRepository<Product, Long>
ListCrudRepository<T, ID>
CrudRepository<T, ID>
Repository<T, ID>
ListPagingAndSortingRepository<T, ID>
PagingAndSortingRepository<T, ID>
Repository<T, ID>
QueryByExampleExecutor<T>The injected bean is a JDK dynamic proxy that implements ProductRepository. A call on it passes through Spring's interceptors, the transaction interceptor (hence TransactionalProxy) and the exception translation that turned PSQLException into InvalidDataAccessResourceUsageException earlier, and lands on a SimpleJpaRepository, Spring Data's one implementation of these interfaces, which calls the EntityManager. The method each interface declares, read with javap from spring-data-commons-4.1.1.jar and spring-data-jpa-4.1.1.jar:
| Interface | Declares |
|---|---|
CrudRepository<T, ID> | save, saveAll, findById, existsById, findAll, findAllById, count, deleteById, delete, deleteAllById, deleteAll |
ListCrudRepository<T, ID> | saveAll, findAll and findAllById again, returning List instead of Iterable |
PagingAndSortingRepository<T, ID>, ListPagingAndSortingRepository<T, ID> | findAll(Sort) and findAll(Pageable), the subject of article 29 |
QueryByExampleExecutor<T> | findOne, findAll, count, exists and findBy taking an Example |
JpaRepository<T, ID> | flush, saveAndFlush, saveAllAndFlush, deleteAllInBatch, deleteAllByIdInBatch, getReferenceById, and List versions of the Example queries |
Every CRUD method and the SQL it sent
This runner calls the methods one by one and logs a marker before each, so every SQL line can be attributed:
package com.example.demo.lab;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("crud")
class CrudTour implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(CrudTour.class);
private final ProductRepository repository;
CrudTour(ProductRepository repository) {
this.repository = repository;
}
@Override
public void run(String... args) {
log.info("-- save(new Product)");
Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25, "Keyboards");
Set<Product> set = new HashSet<>();
set.add(keyboard);
Product saved = repository.save(keyboard);
log.info("id={}, same instance={}, still found in HashSet={}", saved.getId(), saved == keyboard, set.contains(saved));
log.info("-- saveAll(two new products)");
List<Product> more = repository.saveAll(List.of(
new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 3, "Mice"),
new Product("USB-C hub", "HUB-07", new BigDecimal("39.00"), 10, "Accessories")));
log.info("ids={}", more.stream().map(Product::getId).toList());
log.info("-- findById(1)");
Optional<Product> found = repository.findById(1L);
log.info("{}", found);
log.info("-- findById(99)");
log.info("{}", repository.findById(99L));
log.info("-- findAll()");
log.info("{} products", repository.findAll().size());
log.info("-- existsById(2)");
log.info("{}", repository.existsById(2L));
log.info("-- count()");
log.info("{}", repository.count());
log.info("-- save(the product loaded by findById(1), price changed)");
Product loaded = found.orElseThrow();
loaded.setPrice(new BigDecimal("79.90"));
Product merged = repository.save(loaded);
log.info("same instance={}", merged == loaded);
log.info("-- save(a stale copy of product 3 after it was deleted)");
Product hub = repository.findById(3L).orElseThrow();
repository.deleteById(3L);
hub.setStock(0);
try {
Product result = repository.save(hub);
log.info("saved {}", result);
} catch (RuntimeException e) {
log.info("{}: {}", e.getClass().getName(), e.getMessage());
}
log.info("-- delete(product 1, loaded earlier)");
repository.delete(merged);
log.info("-- deleteById(99)");
try {
repository.deleteById(99L);
log.info("no exception");
} catch (RuntimeException e) {
log.info("{}: {}", e.getClass().getName(), e.getMessage());
}
log.info("-- count()");
log.info("{}", repository.count());
}
}The runner has no transaction of its own, so each repository call runs in the transaction that SimpleJpaRepository opens for that one call, with a fresh persistence context. On H2:
CrudTour: -- save(new Product)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
CrudTour: id=1, same instance=true, still found in HashSet=true
CrudTour: -- saveAll(two new products)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
CrudTour: ids=[2, 3]
CrudTour: -- findById(1)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
CrudTour: Optional[Product[id=1, sku=KB-01, price=89.90, stock=25, status=ACTIVE]]
CrudTour: -- findById(99)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
CrudTour: Optional.empty
CrudTour: -- findAll()
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0
CrudTour: 3 products
CrudTour: -- existsById(2)
SQL: select count(*) from products p1_0 where p1_0.id=?
CrudTour: true
CrudTour: -- count()
SQL: select count(*) from products p1_0
CrudTour: 3
CrudTour: -- save(the product loaded by findById(1), price changed)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=? where id=?
CrudTour: same instance=false
CrudTour: -- save(a stale copy of product 3 after it was deleted)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: delete from products where id=?
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
CrudTour: org.springframework.orm.ObjectOptimisticLockingFailureException: Row was already updated or deleted by another transaction for entity [com.example.demo.product.Product with id '3']
CrudTour: -- delete(product 1, loaded earlier)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: delete from products where id=?
CrudTour: -- deleteById(99)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
CrudTour: no exception
CrudTour: -- count()
SQL: select count(*) from products p1_0
CrudTour: 1saveon a new entity sent the INSERT before returning, becauseIDENTITYneeds the database to produce the id. It returned the very instance it was given, now withid=1, and that instance was still found in aHashSetit had been added to before it had an id; thehashCodein the entity is built for that.saveAllissavein a loop inside one transaction: one INSERT per entity.findByIdreturnsOptional<Product>, empty for id 99 after the same query.p1_0is the alias Hibernate gives the table.findAllhas nowhereand noorder by, so the order of the rows is whatever the database returns. Sorting is article 29.existsByIdandcountareselect count(*), with and without thewhere.saveon an entity that has an id did not send an UPDATE straight away.SimpleJpaRepository.saveasksJpaEntityInformation.isNew(entity); without a@Versionfield that means "is the idnull". The answer was no, so it merged: the SELECT loaded row 1 into the new persistence context, the changed state was copied onto that managed instance, and the UPDATE followed.same instance=false: the returned object is the managed copy, which is why code should continue with the valuesavereturns.- A stale copy of a deleted row.
deleteById(3)is itself a SELECT followed by a DELETE. Saving the copy loaded before the delete ran the merge's SELECTs, found no row, and threwObjectOptimisticLockingFailureException. Hibernate 7.4 did not quietly INSERT the product again. deleteon a detached entity also starts with a SELECT, then deletes.deleteByIdon an id that does not exist sent a SELECT, found nothing, and returned normally. Spring Data JPA 4.1.1 threw noEmptyResultDataAccessException, so code that must report a missing id checks first, as the service below does withexistsById.
On PostgreSQL the same runner sent the same statements, with the one difference from the id strategy section, insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?), and ended with the same ObjectOptimisticLockingFailureException and the same count of 1.
The persistence context: dirty checking, first-level cache and detached entities
Every EntityManager has a persistence context: the set of entity instances it has loaded or saved, one per id, which it keeps watching until it closes. In the runner above each call got a new one, so none of its behaviour was visible. Inside one transaction, repository calls share a single persistence context. @Transactional and its rules are the subject of article 30; here it only opens a transaction around a method, which has to live in a separate bean so that the call goes through Spring's proxy:
package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import jakarta.persistence.EntityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("context")
public class PersistenceContextDemo {
private static final Logger log = LoggerFactory.getLogger(PersistenceContextDemo.class);
private final ProductRepository repository;
private final EntityManager entityManager;
PersistenceContextDemo(ProductRepository repository, EntityManager entityManager) {
this.repository = repository;
this.entityManager = entityManager;
}
@Transactional
public void changePriceWithoutSave(Long id) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(new BigDecimal("84.90"));
log.info("price set to 84.90, save() not called, leaving the method");
}
@Transactional
public Product findTwice(Long id) {
Product first = repository.findById(id).orElseThrow();
log.info("first findById returned");
Product second = repository.findById(id).orElseThrow();
log.info("second findById returned, same instance={}, managed={}", first == second, entityManager.contains(first));
return first;
}
}package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import jakarta.persistence.EntityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("context")
class PersistenceContextTour implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(PersistenceContextTour.class);
private final ProductRepository repository;
private final PersistenceContextDemo demo;
private final EntityManager entityManager;
PersistenceContextTour(ProductRepository repository, PersistenceContextDemo demo, EntityManager entityManager) {
this.repository = repository;
this.demo = demo;
this.entityManager = entityManager;
}
@Override
public void run(String... args) {
repository.save(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25, "Keyboards"));
log.info("-- dirty checking");
demo.changePriceWithoutSave(1L);
log.info("method returned, transaction committed");
log.info("-- first-level cache");
Product product = demo.findTwice(1L);
log.info("method returned, transaction committed");
log.info("-- detached");
log.info("managed={}", entityManager.contains(product));
product.setStock(0);
log.info("stock set to 0 on the detached entity");
log.info("stock in the database: {}", repository.findById(1L).orElseThrow().getStock());
log.info("-- save(detached entity)");
repository.save(product);
log.info("stock in the database: {}", repository.findById(1L).orElseThrow().getStock());
}
}On H2:
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
PersistenceContextTour: -- dirty checking
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
PersistenceContextDemo: price set to 84.90, save() not called, leaving the method
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=? where id=?
PersistenceContextTour: method returned, transaction committed
PersistenceContextTour: -- first-level cache
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
PersistenceContextDemo: first findById returned
PersistenceContextDemo: second findById returned, same instance=true, managed=true
PersistenceContextTour: method returned, transaction committed
PersistenceContextTour: -- detached
PersistenceContextTour: managed=false
PersistenceContextTour: stock set to 0 on the detached entity
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
PersistenceContextTour: stock in the database: 25
PersistenceContextTour: -- save(detached entity)
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=? where id=?
SQL: select p1_0.id,p1_0.category,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock from products p1_0 where p1_0.id=?
PersistenceContextTour: stock in the database: 0- Dirty checking. The product loaded by
findByIdis managed: the persistence context kept a snapshot of its state. When the transaction committed, after the method's last line, Hibernate compared the entity with the snapshot, found a different price and sent the UPDATE. Nosavecall was involved. The UPDATE sets every column, not onlyprice. - First-level cache. The second
findById(1)in the same transaction sent no SELECT and returned the same instance: the persistence context already held product 1. This cache lives only as long as the persistence context, one transaction here; it is not shared between requests. - Detached. When
findTwicereturned, its transaction ended and the persistence context closed. The entity object still exists and still holds its data, but nothing watches it:managed=false, and setting the stock to 0 changed nothing in the database, which still said 25. - Merged back.
saveon the detached entity is the merge from the CRUD runner: a SELECT into a new persistence context, then the UPDATE, and the stock is 0.

This is also why a repository method call outside any transaction behaves differently from the same call inside one: the CRUD runner's save(loaded) needed a SELECT because its product was detached, while the price change inside changePriceWithoutSave needed no save at all.
Replacing InMemoryProductRepository with JpaRepository
Article 21's product feature had a four-method ProductRepository and InMemoryProductRepository behind it. The swap deletes InMemoryProductRepository.java and turns the interface into the one-liner shown above. findAll, findById and save are inherited with the same shapes: List<Product>, Optional<Product> and the saved entity. existsBySku has no counterpart in JpaRepository; article 27 brings it back as a derived query method. The order feature and reserveStock stay out of this article's project, because a stock change that is safe under concurrent requests needs the transaction rules of article 30.
The DTOs and the mapper
The request and response records from article 18 gain category, and the request limits each string to its column's length:
package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record CreateProductRequest(
@NotBlank String name,
@NotBlank String sku,
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 40) String sku,
@NotNull @Positive BigDecimal price,
@NotNull @PositiveOrZero Integer stock) {
@NotNull @PositiveOrZero Integer stock,
@NotBlank @Size(max = 60) String category) {
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock) {
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category) {
}@Size repeats the length of each column, so a value that does not fit is stopped by validation, which article 20's handler turns into a 422, instead of by the database. The mapper switches from record accessors to the entity's getters and builds the entity through its public constructor. The entity itself never becomes JSON, for the reasons article 18 gave; here status is one of the fields the API does not expose.
package com.example.demo.product;
import org.springframework.stereotype.Component;
@Component
public class ProductMapper {
public Product toProduct(CreateProductRequest request) {
return new Product(null, request.name(), request.sku(), request.price(), request.stock());
return new Product(request.name(), request.sku(), request.price(), request.stock(), request.category());
}
public ProductResponse toResponse(Product product) {
return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock());
return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
product.getStock(), product.getCategory());
}
}The service: the unique SKU moves into the database
package com.example.demo.product;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public List<Product> findAll() {
return repository.findAll();
}
public Product findById(Long id) {
return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
public Product create(Product product) {
if (repository.existsBySku(product.sku())) {
throw new DuplicateSkuException(product.sku());
}
return repository.save(product);
}
public Product reserveStock(Long id, int quantity) {
Product product = findById(id);
if (product.stock() < quantity) {
throw new InsufficientStockException(product.sku(), product.stock(), quantity);
}
return repository.save(product.withStock(product.stock() - quantity));
}
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new ProductNotFoundException(id);
}
repository.deleteById(id);
}
}findAll, findById and create call the same repository methods as before, so their bodies did not change. The SKU check is gone: article 21 already noted that check-then-save lets two concurrent requests pass the same check, and the unique constraint on sku is the rule nothing can bypass. delete checks existsById first, because deleteById on a missing id returns silently. DuplicateSkuException.java is deleted with the check.
The controller: getters and a DELETE endpoint
package com.example.demo.product;
import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
private final ProductMapper mapper;
public ProductController(ProductService service, ProductMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping
public List<ProductResponse> findAll() {
return service.findAll().stream()
.map(mapper::toResponse)
.toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return mapper.toResponse(service.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product product = service.create(mapper.toProduct(request));
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.id())
.buildAndExpand(product.getId())
.toUri();
return ResponseEntity.created(location).body(mapper.toResponse(product));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}A duplicate SKU: DataIntegrityViolationException on H2 and PostgreSQL
Without the service check, a second product with SKU KB-01 reaches the database. A runner that saves two and prints the exception chain:
package com.example.demo.lab;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("duplicate")
class DuplicateTour implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DuplicateTour.class);
private final ProductRepository repository;
DuplicateTour(ProductRepository repository) {
this.repository = repository;
}
@Override
public void run(String... args) {
repository.save(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25, "Keyboards"));
try {
repository.save(new Product("Compact keyboard", "KB-01", new BigDecimal("59.00"), 5, "Keyboards"));
} catch (RuntimeException e) {
for (Throwable t = e; t != null; t = t.getCause()) {
log.info("{}", t.getClass().getName());
}
log.info("message: {}", e.getMessage());
}
}
}On H2 (the error logger is org.hibernate.orm.jdbc.error):
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
SQL: insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)
error: HHH000247: ErrorCode: 23505, SQLState: 23505
error: Unique index or primary key violation: "PUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_F ON PUBLIC.PRODUCTS(SKU NULLS FIRST) VALUES ( /* 1 */ 'KB-01' )"; SQL statement:
insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default) [23505-240]
DuplicateTour: org.springframework.dao.DataIntegrityViolationException
DuplicateTour: org.hibernate.exception.ConstraintViolationException
DuplicateTour: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException
DuplicateTour: message: could not execute statement [Unique index or primary key violation: "PUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_F ON PUBLIC.PRODUCTS(SKU NULLS FIRST) VALUES ( /* 1 */ 'KB-01' )"; SQL statement:
insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default) [23505-240]] [insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)]; SQL [insert into products (category,name,price,sku,status,stock,id) values (?,?,?,?,?,?,default)]; constraint [PUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_F]On PostgreSQL:
2026-09-13T16:12:50.585+07:00 WARN 9841 --- [demo] [ main] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 0, SQLState: 23505
2026-09-13T16:12:50.585+07:00 WARN 9841 --- [demo] [ main] org.hibernate.orm.jdbc.error : ERROR: duplicate key value violates unique constraint "products_sku_key"
Detail: Key (sku)=(KB-01) already exists.
2026-09-13T16:12:50.589+07:00 INFO 9841 --- [demo] [ main] com.example.demo.lab.DuplicateTour : org.springframework.dao.DataIntegrityViolationException
2026-09-13T16:12:50.589+07:00 INFO 9841 --- [demo] [ main] com.example.demo.lab.DuplicateTour : org.hibernate.exception.ConstraintViolationException
2026-09-13T16:12:50.589+07:00 INFO 9841 --- [demo] [ main] com.example.demo.lab.DuplicateTour : org.postgresql.util.PSQLException
2026-09-13T16:12:50.589+07:00 INFO 9841 --- [demo] [ main] com.example.demo.lab.DuplicateTour : message: could not execute statement [ERROR: duplicate key value violates unique constraint "products_sku_key"
Detail: Key (sku)=(KB-01) already exists.] [insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)]; SQL [insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)]; constraint [products_sku_key]| H2 2.4.240 | PostgreSQL 18.6 | |
|---|---|---|
Thrown by save | DataIntegrityViolationException | DataIntegrityViolationException |
| Hibernate cause | ConstraintViolationException | ConstraintViolationException |
| Driver cause | JdbcSQLIntegrityConstraintViolationException | PSQLException |
| SQLState | 23505 | 23505 |
| Constraint in the message | PUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_F | products_sku_key |
The type your code sees is the same on both: Spring's DataIntegrityViolationException, from org.springframework.dao. Through JPA it is that class itself, not the DuplicateKeyException subclass that JdbcClient produced for the same kind of constraint in article 25, so a handler for DataIntegrityViolationException covers both. The advice maps it to 409 and replaces article 21's DuplicateSkuException handler:
package com.example.demo.common;
import java.util.stream.Collectors;
import com.example.demo.product.DuplicateSkuException;
import com.example.demo.product.ProductNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail notFound(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(DuplicateSkuException.class)
public ProblemDetail conflict(RuntimeException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail conflict(DataIntegrityViolationException e) {
log.warn("Constraint violation: {}", e.getMostSpecificCause().getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The product conflicts with an existing one, for example a duplicate SKU");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail invalid(MethodArgumentNotValidException e) {
String detail = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + " " + error.getDefaultMessage())
.sorted()
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, detail);
}
}The exception's message goes to the log, not to the client: it contains the SQL, the constraint name and, on PostgreSQL, the key value. Handling it in the advice rather than with a try around save also covers statements that are not sent inside save, such as the sequence-based INSERTs that went out only once all ids were known. When the API should say which field clashed, article 27's existsBySku gives the service a check with a precise message, and the constraint stays as the guarantee.
Running the API on PostgreSQL
With the products table dropped, the jar was started against PostgreSQL:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8126 --spring.profiles.active=postgres --spring.jpa.hibernate.ddl-auto=update --spring.jpa.open-in-view=false --logging.level.org.hibernate.SQL=DEBUGupdate created the table with the DDL shown in the ddl-auto section. The first product:
curl -i -s -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25,"category":"Keyboards"}' http://localhost:8126/api/productsHTTP/1.1 201
Location: http://localhost:8126/api/products/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:13:00 GMT
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25,"category":"Keyboards"}A second POST created MS-01, "Wireless mouse", as id 2. Then the same SKU again:
curl -i -s -H 'Content-Type: application/json' -d '{"name":"Compact keyboard","sku":"KB-01","price":59.00,"stock":5,"category":"Keyboards"}' http://localhost:8126/api/productsHTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:13:00 GMT
{"detail":"The product conflicts with an existing one, for example a duplicate SKU","instance":"/api/products","status":409,"title":"Conflict"}The server log for that request:
2026-09-13T16:13:00.916+07:00 DEBUG 9877 --- [demo] [nio-8126-exec-6] org.hibernate.SQL : insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?)
2026-09-13T16:13:00.919+07:00 WARN 9877 --- [demo] [nio-8126-exec-6] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 0, SQLState: 23505
2026-09-13T16:13:00.919+07:00 WARN 9877 --- [demo] [nio-8126-exec-6] org.hibernate.orm.jdbc.error : ERROR: duplicate key value violates unique constraint "ukfhmd06dsmj6k0n90swsh8ie9g"
Detail: Key (sku)=(KB-01) already exists.
2026-09-13T16:13:00.923+07:00 WARN 9877 --- [demo] [nio-8126-exec-6] c.e.demo.common.GlobalExceptionHandler : Constraint violation: ERROR: duplicate key value violates unique constraint "ukfhmd06dsmj6k0n90swsh8ie9g"
Detail: Key (sku)=(KB-01) already exists.The next valid product:
curl -i -s -H 'Content-Type: application/json' -d '{"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10,"category":"Accessories"}' http://localhost:8126/api/productsHTTP/1.1 201
Location: http://localhost:8126/api/products/4
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:13:00 GMT
{"id":4,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10,"category":"Accessories"}Id 4, not 3. The failed INSERT had already taken 3 from the identity column, and PostgreSQL does not give it back: identity columns have gaps too. An unknown id and the delete:
curl -i -s http://localhost:8126/api/products/99
curl -i -s -X DELETE http://localhost:8126/api/products/2
curl -i -s -X DELETE http://localhost:8126/api/products/2HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:13:00 GMT
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Not Found"}
HTTP/1.1 204
Date: Sun, 13 Sep 2026 09:13:01 GMT
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:13:01 GMT
{"detail":"Product 2 not found","instance":"/api/products/2","status":404,"title":"Not Found"}The first DELETE logged select count(*) from products p1_0 where p1_0.id=?, then the SELECT and the DELETE of deleteById; the second logged only the count(*) before the 404. The list at the end:
curl -i -s http://localhost:8126/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 194
Date: Sun, 13 Sep 2026 09:13:01 GMT
[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25,"category":"Keyboards"},{"id":4,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":10,"category":"Accessories"}]Restarted with the same command, the application logged no DDL and the two rows were still there.
spring.jpa.open-in-view and the startup warning
The web run on H2, which did not set the property, logged this during startup:
2026-09-13T16:12:57.781+07:00 WARN 9866 --- [demo] [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warningWith spring.jpa.open-in-view=true, the default in the 4.1.1 metadata, Boot opens an EntityManager when a web request arrives and closes it only when the request is finished, so one persistence context stays open through the controller, the mapping and the JSON serialization after the service has returned. Entities therefore stay managed in the web layer, and any data they load lazily there is fetched with a database connection that the request holds far longer than the service needed it. Turn it off, so that data access ends where the service ends:
spring.application.name=demo
spring.jpa.open-in-view=false The PostgreSQL run above had it set to false and printed no such warning. What goes wrong without the open persistence context is LazyInitializationException, which belongs with relationships in article 28.
equals and hashCode for JPA entities
Inside one persistence context Hibernate guarantees one instance per row, so == works there, as the first-level cache demonstration showed. Across persistence contexts it does not: the merge in the CRUD runner returned a different instance for the same row. An entity therefore needs equals and hashCode, and the obvious implementations are wrong in both directions. Object's identity treats two copies of row 1 as different. A hashCode computed from id changes when save assigns the id, and an entity added to a HashSet before saving can no longer be found in it. A generated equals over all fields makes a product unequal to itself after a price change.
Two approaches hold up:
- Id-based, as in
Productabove: two entities are equal when both have an id and the ids match, andhashCodereturns one constant per class, so it never changes. The CRUD runner confirmed it:still found in HashSet=trueaftersavegave the product its id. It readsother.getId()rather thanother.id, becauseothercan be aProduct$HibernateProxylike the onegetReferenceByIdreturned, and a proxy answers through its methods. - Natural key:
skuis unique, required and has no setter, soequalsandhashCodecan both use it, provided every product gets its SKU when it is created.
Put all products of one table in a big HashSet and the constant hashCode puts them in one bucket, which is slow; that is rarely how entities are used, and the natural key avoids it.
JPA annotations: what each one changed
The right-hand column shows the DDL or SQL each annotation produced earlier in this article.
| Annotation | What it changes | DDL or SQL observed |
|---|---|---|
@Entity | Hibernate manages the class; it needs a no-arg constructor | without one: HHH000182: No default (no-argument) constructor for class [com.example.demo.product.Product], then No default constructor for entity on the first read |
@Table(name = "products") | the table name | create table products (...) |
@Id | the primary key | primary key (id), named products_pkey by PostgreSQL |
@GeneratedValue(strategy = GenerationType.IDENTITY) | the database assigns the id during the INSERT | id bigint generated by default as identity; H2 values (?,?,?,?,?,?,default), PostgreSQL leaves id out |
@GeneratedValue(strategy = GenerationType.SEQUENCE) or @GeneratedValue | ids from a sequence, 50 per call | create sequence products_seq start with 1 increment by 50, select nextval('products_seq') |
@GeneratedValue(strategy = GenerationType.UUID) on a UUID | a random UUID generated in Java | id uuid not null, binding parameter (7:UUID) |
@Column(nullable = false) | a NOT NULL constraint | name varchar(120) not null |
@Column(length = 40) | the varchar size | sku varchar(40) |
@Column(unique = true) | a unique constraint | create: sku varchar(40) not null unique; update: alter table if exists products add constraint UKfhmd06dsmj6k0n90swsh8ie9g unique (sku) |
@Column(precision = 10, scale = 2) | the numeric size | price numeric(10,2) not null |
no annotation on int stock | persistent by default, never null | stock integer not null |
@Enumerated(EnumType.STRING) | stores the constant's name | H2 status enum ('ACTIVE','DISCONTINUED','OUT_OF_STOCK') not null; PostgreSQL status varchar(20) not null check ((status in ('ACTIVE','OUT_OF_STOCK','DISCONTINUED'))) |
@Enumerated (ORDINAL) | stores the constant's position | H2 status tinyint not null check ((status between 0 and 2)); PostgreSQL status smallint not null check ((status between 0 and 2)) |
FAQ
What is the difference between JPA, Hibernate and Spring Data JPA?
JPA, now Jakarta Persistence 3.2, is a specification: annotations and the EntityManager interface, with no database code. Hibernate ORM 7.4.5 implements it and generates and runs the SQL. Spring Data JPA 4.1.1 sits on top of EntityManager and implements repository interfaces such as JpaRepository at runtime, through a proxy backed by SimpleJpaRepository.
What is the default value of spring.jpa.hibernate.ddl-auto in Spring Boot?
It depends on the database. Spring Boot 4.1.1 applies create-drop to an embedded database such as in-memory H2, unless Flyway or Liquibase manages the schema, and none to everything else. The runs printed hibernate.hbm2ddl.auto = create-drop on H2 and null on PostgreSQL, where the first INSERT then failed with relation "products" does not exist.
Does JpaRepository save() insert or update?
It depends on whether the entity is new. SimpleJpaRepository.save asks JpaEntityInformation.isNew, which for an entity without @Version means a null id. A new entity is persisted, which with IDENTITY sends the INSERT at once. An entity with an id is merged: a SELECT, then an UPDATE, and save returns a different, managed instance. If the row was deleted in the meantime, Hibernate 7.4 throws ObjectOptimisticLockingFailureException instead of inserting it again.
Does deleteById throw an exception when the id does not exist?
Not in Spring Data JPA 4.1.1. deleteById(99L) sent one SELECT, found nothing and returned normally, without EmptyResultDataAccessException. To answer 404 for an unknown id, check existsById first.
What does GenerationType.AUTO use in Hibernate 7?
For a Long id Hibernate 7.4.5 used a sequence named products_seq, created with start with 1 increment by 50, on both H2 and PostgreSQL 18, the same as GenerationType.SEQUENCE. Ids went 1, 2, 3 and, after a restart, continued at 52, because each nextval reserves a block of 50.
Can a Java record be a JPA entity?
No. A record annotated with @Entity compiled and started, but saving it failed after the INSERT with Can not set final java.lang.Long field com.example.demo.product.Product.id, and a record has no no-arg constructor for Hibernate to load rows with. Use records for DTOs and a class for the entity.
Should spring.jpa.open-in-view be set to false?
Yes, for a REST API. The default true keeps a persistence context open for the whole web request, which is why Boot logs a warning at startup, and lets entities load data from the controller or during JSON serialization. spring.jpa.open-in-view=false keeps data access inside the service layer.
Conclusion
Spring Data JPA stacks three layers on the DataSource from article 25: the Jakarta Persistence specification, Hibernate ORM 7.4.5 as its implementation, and a Spring Data proxy that implements ProductRepository with SimpleJpaRepository. Annotations on Product decide the table: IDENTITY became an identity column, @Column attributes became sizes and constraints, and EnumType.STRING kept the status safe from a reordered enum, while ORDINAL silently swapped two statuses. Hibernate 7.4 accepts an entity without a no-arg constructor and a record at startup and fails only at run time, and treats a final class as unproxyable. AUTO means a products_seq sequence handing out 50 ids per call, ddl-auto is create-drop only for embedded databases, and the SQL is best watched through logging.level.org.hibernate.SQL and org.hibernate.orm.jdbc.bind.
On the repository side, save persists or merges depending on the id, deleteById is silent about missing rows, and inside one transaction the persistence context turns field changes into UPDATEs and repeated lookups into no SQL at all. The catalogue API now runs on PostgreSQL with the in-memory repository deleted, the unique constraint producing a 409 through DataIntegrityViolationException, and open-in-view switched off.
JpaRepository only knows how to find by id. The next article is about queries: derived query methods such as existsBySku, @Query with JPQL, and native SQL queries.