Command Palette

Search for a command to run...

[Spring Boot Basics] Spring Data JPA and Hibernate in Spring Boot: Entities, @Id, @GeneratedValue and JpaRepository CRUD

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.

An @Entity class on one side, the products table generated from it on the other

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.persistence annotations such as @Entity, @Id and @Column, and the EntityManager interface with persist, find, merge and remove. The jakarta.persistence-api-3.2.0.jar contains 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 SessionImpl is the object behind every EntityManager in 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 class SimpleJpaRepository.

Seven layers under one save() call: ProductRepository, the Spring Data proxy backed by SimpleJpaRepository, EntityManager from Jakarta Persistence, Hibernate ORM, JDBC, the HikariCP DataSource and the database, each marked as your code, Spring, specification or implementation

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:

build.gradle
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'
}

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:

Bash
./gradlew dependencies --configuration runtimeClasspath

Trimmed to the persistence lines:

Text
+--- 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-jdbc is the DataSource and HikariCP pool from article 25. JPA sits on top of it; it does not replace it.
  • spring-boot-data-jpa, spring-boot-hibernate and spring-boot-jpa are Boot 4's JPA auto-configuration, split into modules where Boot 3 had a single spring-boot-autoconfigure jar: spring-boot-hibernate contains HibernateJpaAutoConfiguration, which builds the EntityManagerFactory with Hibernate, spring-boot-data-jpa contains DataJpaRepositoriesAutoConfiguration, which creates the repositories, and spring-boot-jpa holds the shared JPA configuration and the spring.jpa.* properties.
  • jakarta.persistence-api 3.2.0, hibernate-core 7.4.5.Final and spring-data-jpa 4.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:

src/main/java/com/example/demo/product/ProductStatus.java
package com.example.demo.product;
 
public enum ProductStatus {
    ACTIVE, OUT_OF_STOCK, DISCONTINUED
}
src/main/java/com/example/demo/product/Product.java
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 + "]";
    }
}
  • @Entity tells Hibernate to manage the class. Every annotation comes from jakarta.persistence, not from Hibernate.
  • @Table(name = "products") gives the table an explicit name, products.
  • @Id marks the primary key, and @GeneratedValue(strategy = GenerationType.IDENTITY) leaves its value to the database. The strategies get their own section.
  • @Column describes the column: nullable = false becomes not null, length the size of a varchar, unique = true a unique constraint, precision and scale the size of a numeric. These attributes shape the DDL Hibernate generates; they do not validate anything in Java.
  • private int stock has no annotation. Every field of an entity is persistent by default, and a primitive cannot hold null.
  • @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 no setId, because the database assigns it, and no setSku, because a SKU does not change.
  • equals, hashCode and toString are explained in the section on entity equality. toString deliberately prints only a few fields.

The Product class with each annotated field wired to the products column and constraint Hibernate generated for it on PostgreSQL 18.6, with the constraint names products_pkey, products_sku_key and products_status_check

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:

Text
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:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55426/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog

On 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:

Text
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:

Bash
docker exec sb-a26-pg psql -U catalog -d catalog -c '\d products'
Text
                                   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 null came from the primitive int alone.
  • unique = true became an inline unique, which PostgreSQL named products_sku_key.
  • The enum column differs by database. H2 got its native enum type, with the values sorted alphabetically; PostgreSQL got varchar(20) plus a check constraint listing the constants in declaration order.
  • IDENTITY became generated by default as identity on 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:

src/main/java/com/example/demo/product/Product.java
    @Enumerated(EnumType.STRING) 
    @Column(nullable = false, length = 20) 
    @Enumerated
    @Column(nullable = false) 
    private ProductStatus status = ProductStatus.ACTIVE;

The DDL on H2, then on PostgreSQL:

Text
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))
Text
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:

src/main/java/com/example/demo/lab/IdTour.java
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:

Text
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]
Bash
docker exec sb-a26-pg psql -U catalog -d catalog -c 'select id, sku, status from products order by id'
Text
 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:

src/main/java/com/example/demo/product/ProductStatus.java
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:

Text
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:

src/main/java/com/example/demo/product/Product.java
    protected Product() { 
    } 

The application started, saved three products, and failed on the first read (the condition report lines are trimmed):

Text
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:

src/main/java/com/example/demo/product/Product.java
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:

Text
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.Long

The 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:

src/main/java/com/example/demo/lab/ReferenceTour.java
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:

Text
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-01

Against the final one:

Text
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-01

For 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:

Text
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:

Text
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]
Text
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

src/main/java/com/example/demo/product/Product.java
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    @GeneratedValue(strategy = GenerationType.SEQUENCE) 
    private Long id;

The DDL on H2:

Text
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:

Text
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:

Text
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]
Bash
docker exec sb-a26-pg psql -U catalog -d catalog -c "select sequencename, start_value, increment_by, last_value from pg_sequences"
Text
 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:

src/main/java/com/example/demo/product/Product.java
    @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

src/main/java/com/example/demo/product/Product.java
    @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:

Text
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.

Strategyid column on H2 and PostgreSQLWhere the id comes fromIds after a restart (PostgreSQL)
IDENTITYbigint generated by default as identitythe INSERT itself1, 2, 3, then 4, 5, 6
SEQUENCEbigint not null + products_seq, increment by 50nextval('products_seq'), one call per block1, 2, 3, then 52, 53, 54
AUTOidentical to SEQUENCEidentical to SEQUENCE1, 2, 3, then 52, 53, 54
UUIDuuid not nullgenerated in Javarandom

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:

Text
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=true

The 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:

Text
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:

src/main/resources/application.properties
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
Text
2026-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):

Text
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:

src/main/resources/application.properties
spring.jpa.properties.hibernate.format_sql=true
Text
2026-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:

src/main/java/com/example/demo/lab/RepositoryInspector.java
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:

Text
hibernate.hbm2ddl.auto = create-drop
database        : H2 2.4.240 (2025-09-22) at jdbc:h2:mem:d3b1b5b4-d92e-4b0b-a61e-62b69972d717

On PostgreSQL, with only the connection settings:

Text
hibernate.hbm2ddl.auto = null
database        : PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) at jdbc:postgresql://localhost:55426/catalog

null 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:

Bash
javap -c -p -classpath spring-boot-hibernate-4.1.1.jar org.springframework.boot.hibernate.autoconfigure.HibernateDefaultDdlAutoProvider

The method that matters:

Text
  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: areturn

Read 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:

Text
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: 13

The 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

ValueAt startupAt shutdownObserved in these runs
nonenothingnothingthe default on PostgreSQL; relation "products" does not exist on the first INSERT
createdrops the tables and sequences it maps, then creates themnothingdrop table if exists products cascade, then create table products (...); every restart starts empty
create-droplike createdrops them againthe default on in-memory H2; the log ends with drop table if exists products cascade
updatecreates what is missing, adds missing columnsnothingsee below
validatecompares the mapping with the schema, changes nothingnothingstartup fails when something is missing

The end of an H2 run with create-drop:

Text
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 cascade

update on an empty PostgreSQL database did not produce the DDL that create did:

Text
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:

Text
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:

Text
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:

src/main/java/com/example/demo/product/ProductRepository.java
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:

Bash
find build/classes/java/main -name '*.class' | sort | sed 's|build/classes/java/main/||'

Trimmed to the product package:

Text
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.class

The implementation is created at startup. RepositoryInspector from the previous section printed, on H2:

Text
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:

InterfaceDeclares
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:

src/main/java/com/example/demo/lab/CrudTour.java
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:

Text
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: 1
  • save on a new entity sent the INSERT before returning, because IDENTITY needs the database to produce the id. It returned the very instance it was given, now with id=1, and that instance was still found in a HashSet it had been added to before it had an id; the hashCode in the entity is built for that.
  • saveAll is save in a loop inside one transaction: one INSERT per entity.
  • findById returns Optional<Product>, empty for id 99 after the same query. p1_0 is the alias Hibernate gives the table.
  • findAll has no where and no order by, so the order of the rows is whatever the database returns. Sorting is article 29.
  • existsById and count are select count(*), with and without the where.
  • save on an entity that has an id did not send an UPDATE straight away. SimpleJpaRepository.save asks JpaEntityInformation.isNew(entity); without a @Version field that means "is the id null". 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 value save returns.
  • 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 threw ObjectOptimisticLockingFailureException. Hibernate 7.4 did not quietly INSERT the product again.
  • delete on a detached entity also starts with a SELECT, then deletes.
  • deleteById on an id that does not exist sent a SELECT, found nothing, and returned normally. Spring Data JPA 4.1.1 threw no EmptyResultDataAccessException, so code that must report a missing id checks first, as the service below does with existsById.

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:

src/main/java/com/example/demo/lab/PersistenceContextDemo.java
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;
    }
}
src/main/java/com/example/demo/lab/PersistenceContextTour.java
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:

Text
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 findById is 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. No save call was involved. The UPDATE sets every column, not only price.
  • 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 findTwice returned, 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. save on 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.

The entity lifecycle as the SQL log showed it: NEW becomes MANAGED through save and persist with an INSERT, MANAGED holds dirty checking and the first-level cache inside the persistence context, becomes DETACHED when the transaction ends and returns through save and merge with a SELECT and an UPDATE, and becomes REMOVED through delete with a SELECT and a DELETE; three trace cards repeat the logged statements

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:

src/main/java/com/example/demo/product/CreateProductRequest.java
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) { 
}
src/main/java/com/example/demo/product/ProductResponse.java
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.

src/main/java/com/example/demo/product/ProductMapper.java
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

src/main/java/com/example/demo/product/ProductService.java
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

src/main/java/com/example/demo/product/ProductController.java
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:

src/main/java/com/example/demo/lab/DuplicateTour.java
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):

Text
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:

Text
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.240PostgreSQL 18.6
Thrown by saveDataIntegrityViolationExceptionDataIntegrityViolationException
Hibernate causeConstraintViolationExceptionConstraintViolationException
Driver causeJdbcSQLIntegrityConstraintViolationExceptionPSQLException
SQLState2350523505
Constraint in the messagePUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_Fproducts_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:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
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:

Bash
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=DEBUG

update created the table with the DDL shown in the ddl-auto section. The first product:

Bash
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/products
Text
HTTP/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:

Bash
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/products
Text
HTTP/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:

Text
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:

Bash
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/products
Text
HTTP/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:

Bash
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/2
Text
HTTP/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:

Bash
curl -i -s http://localhost:8126/api/products
Text
HTTP/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:

Text
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 warning

With 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:

src/main/resources/application.properties
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 Product above: two entities are equal when both have an id and the ids match, and hashCode returns one constant per class, so it never changes. The CRUD runner confirmed it: still found in HashSet=true after save gave the product its id. It reads other.getId() rather than other.id, because other can be a Product$HibernateProxy like the one getReferenceById returned, and a proxy answers through its methods.
  • Natural key: sku is unique, required and has no setter, so equals and hashCode can 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.

AnnotationWhat it changesDDL or SQL observed
@EntityHibernate manages the class; it needs a no-arg constructorwithout 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 namecreate table products (...)
@Idthe primary keyprimary key (id), named products_pkey by PostgreSQL
@GeneratedValue(strategy = GenerationType.IDENTITY)the database assigns the id during the INSERTid bigint generated by default as identity; H2 values (?,?,?,?,?,?,default), PostgreSQL leaves id out
@GeneratedValue(strategy = GenerationType.SEQUENCE) or @GeneratedValueids from a sequence, 50 per callcreate sequence products_seq start with 1 increment by 50, select nextval('products_seq')
@GeneratedValue(strategy = GenerationType.UUID) on a UUIDa random UUID generated in Javaid uuid not null, binding parameter (7:UUID)
@Column(nullable = false)a NOT NULL constraintname varchar(120) not null
@Column(length = 40)the varchar sizesku varchar(40)
@Column(unique = true)a unique constraintcreate: sku varchar(40) not null unique; update: alter table if exists products add constraint UKfhmd06dsmj6k0n90swsh8ie9g unique (sku)
@Column(precision = 10, scale = 2)the numeric sizeprice numeric(10,2) not null
no annotation on int stockpersistent by default, never nullstock integer not null
@Enumerated(EnumType.STRING)stores the constant's nameH2 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 positionH2 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.

Related Posts

[Spring Boot Basics] JSON with Jackson 3 and DTOs in Spring Boot: Serialization, Deserialization and MapStruct

JSON in Spring Boot 4.1.1 with Jackson 3: JacksonJsonHttpMessageConverter and the jacksonJsonMapper bean, the tools.jackson packages, the immutable JsonMapper and unchecked exceptions, measured Jackson 3 defaults against use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enums and Optional, records, @JsonAlias and @JsonCreator, spring.jackson properties and JsonMapperBuilderCustomizer, why DTOs beat exposing the entity, manual mapping and MapStruct 1.6.3 with Gradle and Maven.

[Spring Boot Basics] Pagination and Sorting in Spring Boot: Pageable, Sort and Paged API Responses

Pagination and sorting in Spring Boot 4.1.1 with Spring Data JPA, on H2 and PostgreSQL: Sort with ignoreCase, nullsFirst and nullsLast and the SQL each produced on both databases, the deprecated TypedSort, zero-based PageRequest, Page vs Slice vs List and the count query and extra row behind each, when Spring Data skips the count, @Query and native queries with Pageable, the inflated totalElements of a paged JOIN FETCH, a Pageable controller with @PageableDefault, max-page-size and one-indexed parameters, the PageImpl serialization warning, PagedModel versus a PageResponse record, a 400 ProblemDetail for an unknown sort property, and OFFSET versus keyset scrolling with Window.

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi on Spring Boot 4.1.1: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] JPA Auditing in Spring Boot: @CreatedDate, @LastModifiedDate and @CreatedBy

Spring Data JPA auditing on Spring Boot 4.1.1 with PostgreSQL: @EnableJpaAuditing, AuditingEntityListener and a @MappedSuperclass base class, a Flyway migration adding NOT NULL audit columns to a table with rows, the silent nulls without the annotation or the listener, Instant vs LocalDateTime vs OffsetDateTime and what timestamptz stores, when @LastModifiedDate moves and what modifyOnCreate changes, the detached save() that writes null into created_at and @Column(updatable = false), @CreatedBy from an X-User header through AuditorAware, a Clock-backed DateTimeProvider, the bulk and native updates that bypass auditing, and Hibernate @CreationTimestamp and @UpdateTimestamp compared.