Command Palette

Search for a command to run...

[Spring Boot Basics] Spring Data JPA và Hibernate trong Spring Boot: Entity, @Id, @GeneratedValue và CRUD với JpaRepository

Bài 21 đặt catalogue sản phẩm sau interface ProductRepository với đúng một implementation là InMemoryProductRepository, còn bài 25 nối application với H2 và PostgreSQL qua DataSource và tự viết SQL bằng JdbcClient. Bài này giao phần SQL cho Hibernate. Product trở thành một JPA entity, Spring Data JPA cung cấp implementation cho repository, và class in-memory bị xoá.

Các ví dụ dùng Spring Boot 4.1.1 và Java 21, với database H2 in-memory và PostgreSQL 18 chạy trong Docker; output nào cũng ghi rõ database nào sinh ra nó. App chạy ở port 8126 thay vì 8080 mặc định, nên bạn sẽ thấy port này trong các lệnh curl.

Một class @Entity ở một bên, table products sinh ra từ nó ở bên kia

Nửa đầu map một entity và quan sát Hibernate làm gì với nó; nửa sau đặt entity đó sau API của Chương 3. Phần lớn log trích dẫn được ghi với logging.pattern.console=%logger{0}: %msg%n, chỉ in tên ngắn của logger và nội dung message; những đoạn log có timestamp dùng pattern mặc định của Boot.

JPA, Hibernate và Spring Data JPA: specification, implementation, repository

Ba cái tên này hay bị dùng lẫn cho nhau, nhưng là ba thứ khác nhau.

  • Jakarta Persistence 3.2, vẫn thường gọi là JPA, là một specification. Nó định nghĩa các annotation trong jakarta.persistence như @Entity, @Id, @Column, và interface EntityManager với persist, find, merge, remove. File jakarta.persistence-api-3.2.0.jar chỉ chứa interface và annotation.
  • Hibernate ORM 7.4.5 là một implementation của specification đó. Nó đọc annotation, sinh SQL cho database mà nó nhận ra, theo dõi các object nó đã load, và chạy statement qua JDBC. SessionImpl của nó là object đứng sau mọi EntityManager trong bài này.
  • Spring Data JPA 4.1.1 là một lớp trừu tượng repository đặt trên EntityManager. Bạn khai báo một interface; lúc khởi động Spring Data tạo một proxy implement interface đó, chạy bằng class SimpleJpaRepository của chính nó.

Bảy layer bên dưới một lời gọi save(): ProductRepository, proxy của Spring Data chạy bằng SimpleJpaRepository, EntityManager của Jakarta Persistence, Hibernate ORM, JDBC, HikariCP DataSource và database, mỗi layer ghi rõ là code của bạn, Spring, specification hay implementation

Code của application làm việc với layer trên cùng, và với EntityManager trong phần minh hoạ persistence context ở sau. Không chỗ nào trong bài import class của Hibernate.

spring-boot-starter-data-jpa mang theo những gì?

Project được sinh với các dependency 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 tự thêm spring-boot-h2console vì chọn cả H2 lẫn web starter, và starter nào cũng đi kèm bản -test của nó. Những gì starter JPA kéo vào:

Bash
./gradlew dependencies --configuration runtimeClasspath

Chỉ giữ các dòng liên quan tới persistence:

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 chính là DataSource và pool HikariCP của bài 25. JPA đứng trên nó chứ không thay nó.
  • spring-boot-data-jpa, spring-boot-hibernatespring-boot-jpa là phần auto-configuration JPA của Boot 4, tách thành nhiều module thay vì một jar spring-boot-autoconfigure như Boot 3: spring-boot-hibernate chứa HibernateJpaAutoConfiguration, nơi dựng EntityManagerFactory bằng Hibernate, spring-boot-data-jpa chứa DataJpaRepositoriesAutoConfiguration, nơi tạo các repository, còn spring-boot-jpa giữ phần config JPA dùng chung và các property spring.jpa.*.
  • jakarta.persistence-api 3.2.0, hibernate-core 7.4.5.Final và spring-data-jpa 4.1.1 lần lượt là specification, implementation và layer repository trong hình.

Map entity đầu tiên: @Entity, @Id, @GeneratedValue và @Column

Product của bài 21 là một record có các method with. Entity là class mà Hibernate tạo được ở trạng thái rỗng, đổ dữ liệu từ một row vào và sửa trực tiếp trên object, nên nó thành một class bình thường. Class giữ các field của bài 21, thêm category làm một column thường, và có thêm status để @Enumerated có thứ mà 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 báo cho Hibernate quản lý class này. Mọi annotation đều đến từ jakarta.persistence, không phải từ Hibernate.
  • @Table(name = "products") đặt tên cho table một cách tường minh, là products.
  • @Id đánh dấu primary key, còn @GeneratedValue(strategy = GenerationType.IDENTITY) để database tự cấp giá trị cho nó. Các strategy có phần riêng ở dưới.
  • @Column mô tả column: nullable = false thành not null, length là độ dài varchar, unique = true là một unique constraint, precisionscale là kích thước của numeric. Các thuộc tính này quyết định DDL mà Hibernate sinh ra; chúng không validate gì trong Java.
  • private int stock không có annotation nào. Mọi field của entity mặc định đều được lưu, và kiểu primitive không chứa được null.
  • @Enumerated(EnumType.STRING) lưu tên của constant. Mặc định là lưu vị trí, và cách đó có một cái bẫy riêng ở phần dưới.
  • protected Product() dành cho Hibernate, vì nó tạo object trước rồi mới đổ giá trị vào field. Constructor public dành cho code của bạn. Không có setId, vì database cấp id, và không có setSku, vì SKU không đổi.
  • equals, hashCodetoString được giải thích ở phần nói về so sánh entity. toString cố tình chỉ in vài field.

Class Product với từng field có annotation được nối tới column và constraint mà Hibernate sinh ra cho nó trên PostgreSQL 18.6, kèm tên các constraint products_pkey, products_sku_key và products_status_check

DDL mà Hibernate sinh ra trên H2 và PostgreSQL

Khi không cấu hình spring.datasource.url, Boot tạo một database H2 in-memory với tên ngẫu nhiên. Khởi động với logging.level.org.hibernate.SQL=DEBUG (phần log SQL sẽ giải thích setting này), log lúc startup trên H2 có:

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

Với PostgreSQL, một profile postgres giữ ba property kết nối mà bài 25 đã giới thiệu, trỏ tới container PostgreSQL 18:

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

Mặc định Boot không sinh DDL nào trên PostgreSQL (phần ddl-auto sẽ cho thấy vì sao), nên lần chạy này thêm 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))

PostgreSQL biến nó thành:

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[]))
  • Thứ tự column là của Hibernate, không phải của bạn: price, stock, id, rồi tới phần còn lại. Điều này không ảnh hưởng gì tới việc map entity.
  • stock integer not null chỉ đến từ kiểu primitive int.
  • unique = true thành một unique viết ngay trong định nghĩa column, được PostgreSQL đặt tên là products_sku_key.
  • Column enum khác nhau theo database. H2 dùng kiểu enum có sẵn của nó, các giá trị bị xếp theo bảng chữ cái; PostgreSQL nhận varchar(20) cộng một check constraint liệt kê các constant theo thứ tự khai báo.
  • IDENTITY thành generated by default as identity trên cả hai.

Bẫy EnumType.ORDINAL

@Enumerated không có tham số nghĩa là EnumType.ORDINAL: database lưu vị trí của constant. Để thấy hậu quả, mapping của status được đổi tạm:

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;

DDL trên H2, rồi trên 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))

Những lần chạy thao tác với repository mà không cần HTTP dùng các CommandLineRunner nhỏ trong package lab, mỗi cái nằm sau một profile riêng, khởi động với --spring.main.web-application-type=none để process tự kết thúc khi runner chạy xong. Runner này lưu ba sản phẩm và được dùng lại ở phần id strategy:

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));
        }
    }
}

Trên PostgreSQL, với ddl-auto=create--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)

Rồi có người sắp xếp các constant theo bảng chữ cái, một thay đổi trông vô hại khi review code:

src/main/java/com/example/demo/product/ProductStatus.java
public enum ProductStatus {
    ACTIVE, OUT_OF_STOCK, DISCONTINUED 
    ACTIVE, DISCONTINUED, OUT_OF_STOCK 
}

Application build lại đọc đúng các row đó, với --tour.mode=read và không có ddl-auto, nên table được giữ nguyên:

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]

Không exception, không warning. Con chuột vừa nãy còn hết hàng giờ thành ngừng kinh doanh, còn cái hub quay lại catalogue ở trạng thái chỉ hết hàng. Các row vẫn giữ 1 và 2, vẫn thoả check (status between 0 and 2); chỉ ý nghĩa của chúng thay đổi. Với EnumType.STRING, row lưu 'OUT_OF_STOCK''DISCONTINUED', nên thứ tự constant không còn quan trọng, còn tên constant trở thành một phần của schema, như check constraint đã cho thấy. Hãy dùng STRING.

Entity thiếu constructor không tham số, record, và class final

Ba biến thể của Product lần nào review code Java 21 cũng gặp, mỗi cái được build và chạy trên H2 với IdTour.

Không có constructor không tham số. Xoá constructor protected:

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

Application khởi động, lưu được ba sản phẩm, rồi fail ở lần đọc đầu tiên (các dòng condition report đã được lược bớt):

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 không từ chối mapping. Nó chỉ log HHH000182 ở mức INFO, rất dễ trôi qua giữa các dòng startup, và class vẫn chạy chừng nào code của bạn còn tự tạo object. Query đầu tiên thì hỏng, vì Hibernate phải tạo một Product rỗng trước rồi mới chép row vào được. Constructor có thể là protected như ở trên, để phần code còn lại không vô tình tạo ra một sản phẩm rỗng.

Một record gắn @Entity. Java 21 khiến cách này rất hấp dẫn:

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) {
}

Nó compile được, application khởi động, table được tạo. Một runner lưu một record, new Product(null, "Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 25, "Keyboards", ProductStatus.ACTIVE), đi được tới đây:

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

Câu INSERT đã được gửi đi, rồi Hibernate không ghi được id vừa sinh vào field final của record. Record cũng không có constructor không tham số, nên việc đọc sẽ fail giống biến thể trước. Entity vốn được thiết kế để mutable: Hibernate gán id cho nó, đổ dữ liệu từ row vào và theo dõi các field thay đổi. Record ở đúng chỗ bài 18 đã đặt: request và response DTO.

Một class final. public final class Product khởi động mà không có warning nào ở mức INFO, và IdTour lưu rồi đọc ba sản phẩm y như trước. Khác biệt lộ ra với getReferenceById, method trả về một reference mà chưa load row. Một runner gọi nó bên trong một 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());
        });
    }
}

Với một Product không final:

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

Với bản final:

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

Với class thường, Hibernate trả về Product$HibernateProxy, một subclass được sinh ra, và chỉ gửi câu SELECT khi getSku() cần dữ liệu. Class final không kế thừa được, nên Hibernate load row ngay tại chỗ và trả về một Product thuần. Ở đây không có gì fail, nhưng lazy loading, thứ mà các quan hệ ở bài 28 phụ thuộc vào, hoạt động thông qua những proxy như vậy. Đừng để entity là final.

Chọn id strategy: IDENTITY, SEQUENCE, AUTO hay UUID

@GeneratedValue có bốn strategy. Với id kiểu Long, lựa chọn nằm giữa IDENTITY, SEQUENCEAUTO; UUID cần field kiểu UUID. Mỗi biến thể dưới đây chạy IdTour một lần trên H2 và hai lần trên PostgreSQL: lần đầu với ddl-auto=create, lần sau bỏ setting đó và thêm --tour.run=2, để xem application khởi động lại cấp những id nào.

IDENTITY: database cấp id ngay trong câu INSERT

Đây chính là mapping ở trên. Trên H2, câu INSERT dùng giá trị default của column:

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]

Trên PostgreSQL, column id không có trong câu lệnh, và lần chạy thứ hai đi tiếp từ chỗ lần đầu dừng lại:

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]

Chỉ database biết giá trị tiếp theo, nên Hibernate phải gửi từng câu INSERT ngay khi entity được save để biết id của nó. Phần CRUD cho thấy thứ tự đó trong log.

SEQUENCE và AUTO: products_seq, 50 id cho mỗi lần gọi

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

DDL trên 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))

Column id mất generated by default as identity, và xuất hiện một sequence mang tên table, với increment by 50. Trên H2, log còn có một câu create global temporary table HTE_products(...) trước các câu này; đó là table riêng của Hibernate, không thuộc mapping. PostgreSQL nhận đúng sequence đó, create sequence products_seq start with 1 increment by 50. Lần chạy đầu tiên trên đó:

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]

Sau khi khởi động lại:

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)

Với increment by 50, mỗi lần gọi nextval phủ một khối id mà Hibernate phát ra từ bộ nhớ. Trên sequence mới tinh, lần gọi đầu trả về 1 và Hibernate gọi thêm lần nữa; ba sản phẩm nhận 1, 2 và 3. Application khởi động lại không hề biết phần chưa dùng của khối cũ, gọi nextval một lần, nhận 101, và đánh số từ 52. Khoảng trống đó là có chủ ý, và không có gì nên phụ thuộc vào việc id liên tiếp. Các câu INSERT giờ mang id dưới dạng bind parameter, và được gửi sau khi đã có đủ cả ba id chứ không gửi từng câu một.

Tiếp theo là @GeneratedValue không có strategy, tức là AUTO:

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

Trên cả hai database, AUTO cho ra đúng output của SEQUENCE: cùng products_seq với increment by 50, cùng các lần gọi nextval, id 1, 2, 3 rồi 52, 53, 54 sau khi khởi động lại, và chỉ một row trong pg_sequences. Với id kiểu Long, Hibernate 7.4 hiểu AUTO là một sequence cho mỗi entity, không phải IDENTITY và cũng không phải một sequence dùng chung cho mọi table.

Id kiểu UUID

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

Type argument thứ hai của repository cũng đổi thành UUID, JpaRepository<Product, UUID>, và getId() trả về UUID. Column trên H2PostgreSQL đều là id uuid not null, không có sequence. Khi bật log bind parameter, câu INSERT đầu tiên trên 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]

Id được sinh trong Java trước câu INSERT, không cần gọi database. Cả sáu id mà hai lần chạy tạo ra, chẳng hạn c74d4195-3340-49d3-af5a-e2f89d785d9fffaf59aa-c2db-45e4-aa45-ac1161cceaee, đều có số 4 ở đầu nhóm thứ ba: UUID ngẫu nhiên, version 4. Id UUID tạo được ở bất cứ đâu và không để lộ số lượng row; đổi lại, gõ nó vào URL dài hơn nhiều so với /api/products/42.

StrategyColumn id trên H2 và PostgreSQLId đến từ đâuId sau khi khởi động lại (PostgreSQL)
IDENTITYbigint generated by default as identitychính câu INSERT1, 2, 3, rồi 4, 5, 6
SEQUENCEbigint not null + products_seq, increment by 50nextval('products_seq'), một lần gọi cho mỗi khối1, 2, 3, rồi 52, 53, 54
AUTOgiống hệt SEQUENCEgiống hệt SEQUENCE1, 2, 3, rồi 52, 53, 54
UUIDuuid not nullsinh trong Javangẫu nhiên

Series giữ IDENTITY: định nghĩa table tự đứng một mình, hợp với các migration script mà bài 31 viết tay, và id dễ đọc trong URL lẫn log.

Xem SQL mà Hibernate gửi đi

Mọi thứ ở trên đều dựa vào việc đọc các câu lệnh Hibernate gửi đi. Có hai công tắc cho việc đó, và một cái tốt hơn.

spring.jpa.show-sql hay logging.level.org.hibernate.SQL

spring.jpa.show-sql=true, có giá trị mặc định là false trong metadata của 4.1.1, cho ra đoạn này khi chạy runner CRUD ở phần sau:

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

Dòng Hibernate: không có timestamp, level, thread hay tên logger. Hibernate ghi thẳng nó ra standard output, đi vòng qua Logback, nên không cấu hình logging nào áp lên nó được: không ghi ra file log, không lọc được, không bật riêng cho một profile bằng log level. logging.level.org.hibernate.SQL=DEBUG, setting đã dùng cho mọi đoạn DDL từ đầu bài, đưa cùng nội dung đó qua logger org.hibernate.SQL:

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)

Hãy dùng logger và để show-sql tắt.

Giá trị bind parameter với org.hibernate.orm.jdbc.bind

Các câu lệnh chỉ hiện dấu ?. Giá trị được bind vào chúng được log ở mức TRACE bởi category org.hibernate.orm.jdbc.bind trong 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]

Và với 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]

Mỗi dòng cho biết vị trí, JDBC type và giá trị. Trên H2, status được bind dưới dạng ENUM, đúng kiểu có sẵn mà column của nó nhận. Id không có trong danh sách của câu INSERT, vì IDENTITY để nó cho default trong câu lệnh. Log bind ghi lại mọi giá trị application lưu, kể cả dữ liệu khách hàng, nên chỉ nên dùng khi debug ở máy local, không đưa vào log production.

Format SQL với hibernate.format_sql

Mọi setting của Hibernate đều truyền được qua prefix spring.jpa.properties.. hibernate.format_sql bẻ mỗi câu lệnh ra nhiều dòng:

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)

Nó giúp đọc các query dài, nhưng khiến log khó tìm bằng grep hơn. Phần còn lại của bài để nó tắt.

spring.jpa.hibernate.ddl-auto: Spring Boot áp dụng gì trên H2 và PostgreSQL

Trên H2, table tự xuất hiện mà không cần setting nào; trên PostgreSQL thì không. spring.jpa.hibernate.ddl-auto không có giá trị mặc định cố định trong property metadata của Boot, vì Boot quyết định lúc khởi động. Runner này in ra giá trị mà Hibernate thực sự nhận được, cùng database mà nó đang nói chuyện; mấy dòng đầu của nó dùng cho phần sau:

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);
        }
    }
}

Trên H2, không cấu hình gì:

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

Trên PostgreSQL, chỉ có thông tin kết nối:

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

null nghĩa là Boot không truyền gì và Hibernate không đụng gì vào schema. Quyết định này nằm trong HibernateDefaultDdlAutoProvider, thuộc 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

Method quan trọng:

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

Đọc theo kiểu Java: database không phải embedded nhận none; database embedded cũng nhận none nếu schema đã được một công cụ như Flyway hay Liquibase quản lý; mọi database embedded còn lại nhận create-drop. H2 in-memory được tính là embedded, PostgreSQL thì không. Không có table, câu INSERT đầu tiên trên PostgreSQL fail:

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

Chuỗi exception này có đúng ba tầng như mọi lỗi database khác trong bài: một subclass DataAccessException của Spring, exception của Hibernate mà nó bọc, và exception của JDBC driver ở dưới cùng.

create, create-drop, update, validate và none

Giá trịLúc khởi độngLúc tắtQuan sát được trong các lần chạy
nonekhông làm gìkhông làm gìmặc định trên PostgreSQL; relation "products" does not exist ở câu INSERT đầu tiên
createdrop các table và sequence nó map, rồi tạo lạikhông làm gìdrop table if exists products cascade, rồi create table products (...); lần khởi động nào cũng bắt đầu với table rỗng
create-dropgiống createdrop chúng lần nữamặc định trên H2 in-memory; log kết thúc bằng drop table if exists products cascade
updatetạo những gì còn thiếu, thêm column còn thiếukhông làm gìxem bên dưới
validateso mapping với schema, không thay đổi gìkhông làm gìkhởi động fail khi thiếu thứ gì đó

Phần cuối của một lần chạy H2 với 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 trên một database PostgreSQL trống không sinh ra DDL giống create:

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)

Các column theo thứ tự khác, và unique constraint đến bằng một câu alter table riêng với tên được sinh ra, UKfhmd06dsmj6k0n90swsh8ie9g, mà PostgreSQL lưu ở dạng chữ thường. Dòng WARN là thông báo của PostgreSQL rằng không có gì để drop. Khởi động lần hai với update trên table đó không log câu DDL nào.

Để thử hai giá trị còn lại, entity tạm thời có thêm một field, @Column(length = 80) private String brand;, và chạy trên table do create tạo từ mapping chưa có field đó. validate từ chối khởi động:

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 thêm column rồi khởi động:

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)

Chính sự tiện lợi đó khiến update hấp dẫn, và cũng là lý do nó không phải công cụ migration. Nó không ghi lại những gì đã thay đổi, không ai review câu DDL trước khi chạy, và table nó dựng từ con số không cũng không giống table mà create dựng, như hai đoạn log trên cho thấy. Cứ để H2 in-memory với giá trị mặc định; trên PostgreSQL, chương này dùng update cho các thử nghiệm. Bài 31 thay ddl-auto bằng migration của Flyway.

JpaRepository: CRUD mà không cần class implementation

Cây interface và proxy phía sau

Đây là toàn bộ 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> {
}

Hai type argument là entity và kiểu của @Id. Không class nào implement interface này, và kết quả build của project hoàn chỉnh xác nhận không có gì được sinh ra lúc compile:

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

Chỉ giữ package product:

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

Implementation được tạo lúc khởi động. RepositoryInspector ở phần trước in ra, trên 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>

Bean được inject là một JDK dynamic proxy implement ProductRepository. Một lời gọi lên nó đi qua các interceptor của Spring, gồm transaction interceptor (vì thế mới có TransactionalProxy) và phần exception translation đã đổi PSQLException thành InvalidDataAccessResourceUsageException ở trên, rồi tới một SimpleJpaRepository, implementation duy nhất của Spring Data cho các interface này, và nó gọi EntityManager. Method mà từng interface khai báo, đọc bằng javap từ spring-data-commons-4.1.1.jarspring-data-jpa-4.1.1.jar:

InterfaceKhai báo
CrudRepository<T, ID>save, saveAll, findById, existsById, findAll, findAllById, count, deleteById, delete, deleteAllById, deleteAll
ListCrudRepository<T, ID>lại saveAll, findAllfindAllById, nhưng trả về List thay vì Iterable
PagingAndSortingRepository<T, ID>, ListPagingAndSortingRepository<T, ID>findAll(Sort)findAll(Pageable), chủ đề của bài 29
QueryByExampleExecutor<T>findOne, findAll, count, existsfindBy nhận một Example
JpaRepository<T, ID>flush, saveAndFlush, saveAllAndFlush, deleteAllInBatch, deleteAllByIdInBatch, getReferenceById, và bản trả List của các query theo Example

Từng method CRUD và câu SQL nó gửi đi

Runner này gọi từng method một và log một dòng đánh dấu trước mỗi lời gọi, để biết mỗi dòng SQL thuộc về đâu:

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());
    }
}

Runner không có transaction riêng, nên mỗi lời gọi repository chạy trong transaction mà SimpleJpaRepository mở cho riêng lời gọi đó, với một persistence context mới. Trên 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 với entity mới gửi câu INSERT trước khi return, vì IDENTITY cần database sinh id. Nó trả về đúng instance được truyền vào, giờ đã có id=1, và instance đó vẫn tìm thấy trong một HashSet mà nó được thêm vào khi chưa có id; hashCode của entity được viết để làm được việc đó.
  • saveAllsave trong một vòng lặp, cùng một transaction: mỗi entity một câu INSERT.
  • findById trả về Optional<Product>, rỗng với id 99 sau cùng câu query đó. p1_0 là alias Hibernate đặt cho table.
  • findAll không có where lẫn order by, nên thứ tự row là thứ tự database trả về. Sắp xếp là chủ đề của bài 29.
  • existsByIdcountselect count(*), có và không có where.
  • save với entity đã có id không gửi UPDATE ngay. SimpleJpaRepository.save hỏi JpaEntityInformation.isNew(entity); khi không có field @Version, câu hỏi đó nghĩa là "id có null không". Câu trả lời là không, nên nó merge: câu SELECT load row 1 vào persistence context mới, trạng thái đã sửa được chép lên instance managed đó, rồi tới câu UPDATE. same instance=false: object trả về là bản managed, nên code phải làm tiếp với giá trị mà save trả về.
  • Một bản sao cũ của row đã bị xoá. Bản thân deleteById(3) là một câu SELECT rồi một câu DELETE. Save bản sao đã load trước khi xoá chạy các câu SELECT của merge, không thấy row nào, và ném ObjectOptimisticLockingFailureException. Hibernate 7.4 không lặng lẽ INSERT sản phẩm đó lại.
  • delete với entity detached cũng bắt đầu bằng một câu SELECT, rồi mới xoá.
  • deleteById với id không tồn tại gửi một câu SELECT, không thấy gì, và return bình thường. Spring Data JPA 4.1.1 không ném EmptyResultDataAccessException, nên code nào cần báo id không tồn tại phải kiểm tra trước, như service bên dưới làm với existsById.

Trên PostgreSQL, cùng runner đó gửi đúng các câu lệnh ấy, chỉ khác một chỗ đã thấy ở phần id strategy, insert into products (category,name,price,sku,status,stock) values (?,?,?,?,?,?), và kết thúc với cùng ObjectOptimisticLockingFailureException, cùng count bằng 1.

Persistence context: dirty checking, first-level cache và entity detached

Mỗi EntityManager có một persistence context: tập các entity instance nó đã load hoặc save, mỗi id một instance, được nó theo dõi cho tới khi đóng lại. Trong runner ở trên, mỗi lời gọi nhận một persistence context mới, nên không thấy được hành vi nào của nó. Bên trong một transaction, các lời gọi repository dùng chung một persistence context. @Transactional và các quy tắc của nó là chủ đề của bài 30; ở đây nó chỉ mở một transaction bao quanh một method, và method đó phải nằm ở một bean riêng để lời gọi đi qua proxy của Spring:

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());
    }
}

Trên 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. Sản phẩm mà findById load về đang ở trạng thái managed: persistence context giữ một bản chụp trạng thái của nó. Khi transaction commit, sau dòng cuối của method, Hibernate so entity với bản chụp, thấy price khác và gửi câu UPDATE. Không có lời gọi save nào. Câu UPDATE set mọi column, không chỉ price.
  • First-level cache. Lần findById(1) thứ hai trong cùng transaction không gửi câu SELECT nào và trả về đúng instance cũ: persistence context đã giữ sản phẩm 1. Cache này chỉ sống cùng persistence context, ở đây là một transaction; nó không được chia sẻ giữa các request.
  • Detached. Khi findTwice return, transaction của nó kết thúc và persistence context đóng lại. Object entity vẫn tồn tại và vẫn giữ dữ liệu, nhưng không còn ai theo dõi nó: managed=false, và đặt stock về 0 không thay đổi gì trong database, nơi vẫn là 25.
  • Merge trở lại. save trên entity detached chính là merge đã thấy ở runner CRUD: một câu SELECT vào persistence context mới, rồi câu UPDATE, và stock thành 0.

Vòng đời của entity theo đúng log SQL: NEW thành MANAGED qua save và persist cùng một câu INSERT, MANAGED có dirty checking và first-level cache bên trong persistence context, thành DETACHED khi transaction kết thúc và quay lại qua save và merge với một câu SELECT và một câu UPDATE, thành REMOVED qua delete với một câu SELECT và một câu DELETE; ba thẻ trace lặp lại các câu lệnh đã log

Đây cũng là lý do một lời gọi repository nằm ngoài mọi transaction chạy khác với cùng lời gọi đó nằm trong một transaction: save(loaded) của runner CRUD cần một câu SELECT vì sản phẩm của nó đã detached, còn việc đổi price bên trong changePriceWithoutSave không cần save nào cả.

Thay InMemoryProductRepository bằng JpaRepository

Feature product của bài 21 có một ProductRepository bốn method và InMemoryProductRepository đứng sau. Việc thay thế xoá InMemoryProductRepository.java và biến interface thành bản một dòng ở trên. findAll, findByIdsave được kế thừa với cùng hình dạng: List<Product>, Optional<Product> và entity đã lưu. existsBySku không có method tương ứng trong JpaRepository; bài 27 đưa nó trở lại dưới dạng derived query method. Feature order và reserveStock nằm ngoài project của bài này, vì thay đổi stock an toàn khi có nhiều request đồng thời cần đến các quy tắc transaction của bài 30.

DTO và mapper

Các record request và response từ bài 18 có thêm category, và request giới hạn mỗi chuỗi theo length của column:

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 lặp lại length của từng column, nên giá trị không vừa sẽ bị validation chặn, và handler của bài 20 biến lỗi đó thành 422, thay vì để database chặn. Mapper chuyển từ accessor của record sang getter của entity và tạo entity qua constructor public. Bản thân entity không bao giờ thành JSON, vì những lý do bài 18 đã nêu; ở đây status là một trong những field API không để lộ ra.

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()); 
    }
}

Service: SKU không trùng chuyển xuống 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, findByIdcreate gọi đúng các method repository như trước, nên thân method không đổi. Phần kiểm tra SKU đã bỏ: bài 21 đã lưu ý rằng kiểu kiểm tra rồi mới lưu để hai request đồng thời cùng qua được một lần kiểm tra, còn constraint unique trên sku là quy tắc không gì đi vòng qua được. delete kiểm tra existsById trước, vì deleteById với id không tồn tại chỉ return im lặng. DuplicateSkuException.java bị xoá cùng với phần kiểm tra.

Controller: getter và endpoint DELETE

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(); 
    } 
}

SKU bị trùng: DataIntegrityViolationException trên H2 và PostgreSQL

Không còn kiểm tra ở service, một sản phẩm thứ hai có SKU KB-01 sẽ đi tới tận database. Một runner lưu hai sản phẩm và in chuỗi exception:

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());
        }
    }
}

Trên H2 (logger errororg.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]

Trên 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
save ném raDataIntegrityViolationExceptionDataIntegrityViolationException
Cause từ HibernateConstraintViolationExceptionConstraintViolationException
Cause từ driverJdbcSQLIntegrityConstraintViolationExceptionPSQLException
SQLState2350523505
Constraint trong messagePUBLIC.CONSTRAINT_F INDEX PUBLIC.CONSTRAINT_INDEX_Fproducts_sku_key

Type mà code của bạn nhận được giống nhau trên cả hai: DataIntegrityViolationException của Spring, thuộc org.springframework.dao. Qua JPA, đó chính là class này, không phải subclass DuplicateKeyExceptionJdbcClient sinh ra cho cùng loại constraint ở bài 25, nên một handler cho DataIntegrityViolationException bắt được cả hai. Advice map nó sang 409, thay cho handler DuplicateSkuException của bài 21:

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);
    }
}

Message của exception đi vào log chứ không tới client: nó chứa câu SQL, tên constraint, và trên PostgreSQL là cả giá trị của key. Xử lý nó trong advice thay vì bọc try quanh save còn bắt được cả những câu lệnh không được gửi bên trong save, như các câu INSERT dùng sequence chỉ được gửi khi đã có đủ id. Khi API cần nói rõ field nào bị trùng, existsBySku của bài 27 cho service một phép kiểm tra với message chính xác, còn constraint vẫn là lớp bảo đảm cuối cùng.

Chạy API trên PostgreSQL

Sau khi drop table products, jar được khởi động với 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 tạo table bằng câu DDL đã thấy ở phần ddl-auto. Sản phẩm đầu tiên:

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"}

Một request POST thứ hai tạo MS-01, "Wireless mouse", với id 2. Rồi gửi lại đúng SKU cũ:

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"}

Log phía server của 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.

Sản phẩm hợp lệ tiếp theo:

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, không phải 3. Câu INSERT thất bại đã lấy mất số 3 từ identity column, và PostgreSQL không trả lại: identity column cũng có khoảng trống. Một id không tồn tại, rồi thao tác xoá:

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"}

Request DELETE đầu tiên log select count(*) from products p1_0 where p1_0.id=?, rồi câu SELECT và câu DELETE của deleteById; request thứ hai chỉ log count(*) trước khi trả 404. Danh sách cuối cùng:

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"}]

Khởi động lại với đúng lệnh đó, application không log câu DDL nào và hai row vẫn còn nguyên.

spring.jpa.open-in-view và cảnh báo lúc khởi động

Lần chạy web trên H2, vốn không đặt property này, log dòng sau lúc khởi động:

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

Với spring.jpa.open-in-view=true, giá trị mặc định trong metadata của 4.1.1, Boot mở một EntityManager khi web request tới và chỉ đóng nó khi request kết thúc, nên một persistence context mở suốt controller, phần map DTO và quá trình serialize JSON, kể cả sau khi service đã return. Vì vậy entity vẫn ở trạng thái managed trong web layer, và dữ liệu nào được load lazy ở đó sẽ dùng một connection database mà request giữ lâu hơn nhiều so với lúc service thực sự cần. Hãy tắt nó, để việc truy cập dữ liệu kết thúc đúng chỗ service kết thúc:

src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false 

Lần chạy trên PostgreSQL ở trên đặt nó thành false và không in cảnh báo nào. Điều gì hỏng khi không còn persistence context mở là LazyInitializationException, thuộc về phần quan hệ ở bài 28.

equals và hashCode cho JPA entity

Bên trong một persistence context, Hibernate đảm bảo mỗi row chỉ có một instance, nên == dùng được ở đó, như phần first-level cache đã cho thấy. Giữa các persistence context thì không: phép merge trong runner CRUD trả về một instance khác cho cùng một row. Vì thế entity cần equalshashCode, và những cách viết hiển nhiên đều sai theo cả hai hướng. Identity mặc định của Object coi hai bản sao của row 1 là khác nhau. hashCode tính từ id sẽ đổi khi save gán id, và một entity đã được thêm vào HashSet trước khi save sẽ không tìm lại được trong đó. equals được sinh ra trên mọi field khiến một sản phẩm không còn bằng chính nó sau khi đổi giá.

Có hai cách đứng vững:

  • Dựa trên id, như trong Product ở trên: hai entity bằng nhau khi cả hai đều có id và id trùng nhau, còn hashCode trả về một hằng số cho mỗi class nên không bao giờ đổi. Runner CRUD đã xác nhận điều này: still found in HashSet=true sau khi save cấp id cho sản phẩm. Code đọc other.getId() thay vì other.id, vì other có thể là một Product$HibernateProxy giống cái mà getReferenceById trả về, và proxy trả lời thông qua các method của nó.
  • Natural key: sku là duy nhất, bắt buộc và không có setter, nên cả equals lẫn hashCode đều dùng được nó, miễn là mọi sản phẩm đều có SKU ngay khi được tạo.

Nếu cho mọi sản phẩm của một table vào một HashSet lớn, hashCode hằng số sẽ dồn tất cả vào một bucket và chạy chậm; entity hiếm khi được dùng như vậy, và natural key tránh được chuyện đó.

Các JPA annotation: mỗi cái đã thay đổi gì

Cột bên phải là DDL hoặc SQL mà mỗi annotation đã sinh ra ở các phần trước của bài.

AnnotationThay đổi gìDDL hoặc SQL quan sát được
@EntityHibernate quản lý class; class cần constructor không tham sốthiếu nó: HHH000182: No default (no-argument) constructor for class [com.example.demo.product.Product], rồi No default constructor for entity ở lần đọc đầu tiên
@Table(name = "products")tên tablecreate table products (...)
@Idprimary keyprimary key (id), được PostgreSQL đặt tên products_pkey
@GeneratedValue(strategy = GenerationType.IDENTITY)database cấp id trong câu INSERTid bigint generated by default as identity; H2 values (?,?,?,?,?,?,default), PostgreSQL bỏ id ra khỏi câu lệnh
@GeneratedValue(strategy = GenerationType.SEQUENCE) hoặc @GeneratedValueid lấy từ sequence, 50 id mỗi lần gọicreate sequence products_seq start with 1 increment by 50, select nextval('products_seq')
@GeneratedValue(strategy = GenerationType.UUID) trên field UUIDUUID ngẫu nhiên sinh trong Javaid uuid not null, binding parameter (7:UUID)
@Column(nullable = false)constraint NOT NULLname varchar(120) not null
@Column(length = 40)độ dài varcharsku varchar(40)
@Column(unique = true)unique constraintcreate: sku varchar(40) not null unique; update: alter table if exists products add constraint UKfhmd06dsmj6k0n90swsh8ie9g unique (sku)
@Column(precision = 10, scale = 2)kích thước numericprice numeric(10,2) not null
không có annotation trên int stockmặc định được lưu, không bao giờ nullstock integer not null
@Enumerated(EnumType.STRING)lưu tên constantH2 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)lưu vị trí constantH2 status tinyint not null check ((status between 0 and 2)); PostgreSQL status smallint not null check ((status between 0 and 2))

FAQ

JPA, Hibernate và Spring Data JPA khác nhau thế nào?

JPA, nay là Jakarta Persistence 3.2, là một specification: các annotation và interface EntityManager, không có code nào làm việc với database. Hibernate ORM 7.4.5 implement nó, sinh và chạy SQL. Spring Data JPA 4.1.1 đứng trên EntityManager và implement các repository interface như JpaRepository lúc runtime, qua một proxy chạy bằng SimpleJpaRepository.

Giá trị mặc định của spring.jpa.hibernate.ddl-auto trong Spring Boot là gì?

Tuỳ database. Spring Boot 4.1.1 áp dụng create-drop cho database embedded như H2 in-memory, trừ khi Flyway hoặc Liquibase quản lý schema, và none cho mọi database khác. Các lần chạy in ra hibernate.hbm2ddl.auto = create-drop trên H2 và null trên PostgreSQL, nơi câu INSERT đầu tiên sau đó fail với relation "products" does not exist.

save() của JpaRepository là insert hay update?

Tuỳ entity có mới hay không. SimpleJpaRepository.save hỏi JpaEntityInformation.isNew, và với entity không có @Version thì câu hỏi đó nghĩa là id có null không. Entity mới được persist, với IDENTITY là gửi INSERT ngay. Entity đã có id được merge: một câu SELECT, rồi một câu UPDATE, và save trả về một instance khác đang managed. Nếu row đã bị xoá trong lúc đó, Hibernate 7.4 ném ObjectOptimisticLockingFailureException chứ không INSERT lại.

deleteById có ném exception khi id không tồn tại không?

Trong Spring Data JPA 4.1.1 thì không. deleteById(99L) gửi một câu SELECT, không thấy gì và return bình thường, không có EmptyResultDataAccessException. Muốn trả 404 cho id không tồn tại, hãy kiểm tra existsById trước.

GenerationType.AUTO trong Hibernate 7 dùng gì?

Với id kiểu Long, Hibernate 7.4.5 dùng một sequence tên products_seq, tạo bằng start with 1 increment by 50, trên cả H2 lẫn PostgreSQL 18, giống hệt GenerationType.SEQUENCE. Id đi 1, 2, 3 và sau khi khởi động lại thì tiếp từ 52, vì mỗi lần nextval giữ một khối 50 id.

Java record có làm JPA entity được không?

Không. Một record gắn @Entity compile và khởi động được, nhưng save thì fail sau câu INSERT với Can not set final java.lang.Long field com.example.demo.product.Product.id, và record không có constructor không tham số để Hibernate load row. Hãy dùng record cho DTO và class cho entity.

Có nên đặt spring.jpa.open-in-view thành false không?

Có, với REST API. Giá trị mặc định true giữ một persistence context mở suốt web request, đó là lý do Boot log cảnh báo lúc khởi động, và cho phép entity load dữ liệu từ controller hoặc trong lúc serialize JSON. spring.jpa.open-in-view=false giữ việc truy cập dữ liệu nằm trong service layer.

Kết luận

Spring Data JPA đặt ba tầng lên trên DataSource của bài 25: specification Jakarta Persistence, Hibernate ORM 7.4.5 là implementation của nó, và một proxy của Spring Data implement ProductRepository bằng SimpleJpaRepository. Annotation trên Product quyết định table: IDENTITY thành một identity column, các thuộc tính của @Column thành kích thước và constraint, còn EnumType.STRING giữ status an toàn trước một enum bị sắp xếp lại, trong khi ORDINAL lặng lẽ tráo hai status cho nhau. Hibernate 7.4 chấp nhận entity thiếu constructor không tham số và cả record lúc khởi động, rồi chỉ fail lúc runtime, và coi class final là không tạo proxy được. AUTO nghĩa là sequence products_seq phát 50 id mỗi lần gọi, ddl-auto chỉ là create-drop với database embedded, và SQL nên được theo dõi qua logging.level.org.hibernate.SQLorg.hibernate.orm.jdbc.bind.

Về phía repository, save persist hay merge tuỳ vào id, deleteById im lặng với row không tồn tại, và bên trong một transaction, persistence context biến thay đổi trên field thành câu UPDATE, còn các lần tìm lặp lại thì không tốn câu SQL nào. Catalogue API giờ chạy trên PostgreSQL, repository in-memory đã bị xoá, unique constraint trả về 409 qua DataIntegrityViolationException, và open-in-view đã tắt.

JpaRepository chỉ biết tìm theo id. Bài tiếp theo: truy vấn — derived query method như existsBySku, @Query với JPQL và native query.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.

[Spring Boot Basics] Bean scope và lifecycle trong Spring: singleton, prototype, @PostConstruct và @PreDestroy

Bean scope và bean lifecycle trong Spring trên Spring Boot 4.1.1: vì sao singleton là một instance cho mỗi container chứ không phải cho cả JVM, so sánh singleton, prototype, request, session và application bằng số instance đếm được qua curl, vì sao @PreDestroy không bao giờ chạy với prototype, cái bẫy singleton giữ prototype cùng ba cách sửa ObjectProvider, @Lookup và scoped proxy, đủ mười bốn bước lifecycle lần theo từng callback, và @Lazy thực sự đánh đổi những gì.

[Spring Boot Basics] Đóng gói và chạy ứng dụng Spring Boot: JAR thực thi, profile và Dockerfile đơn giản

Đóng gói và chạy ứng dụng Spring Boot 4.1.1: ./gradlew build so với bootJar và file -plain.jar, bên trong JAR thực thi có gì (MANIFEST.MF, JarLauncher, BOOT-INF/classes, BOOT-INF/lib, classpath.idx), đặt tên artifact với Gradle và Maven, java -jar với profile postgres trên PostgreSQL, environment variable và file config bên ngoài cho password, graceful shutdown và exit code, Dockerfile trên eclipse-temurin:21-jre, ENTRYPOINT dạng exec và dạng shell đo bằng docker stop, user không phải root, multi-stage build và thứ mà layer dependency của Gradle thực sự cache, heap mặc định khi chạy với --memory=512m, và ứng dụng cùng PostgreSQL trên Docker network và trong Docker Compose.