Đến giờ catalogue chạy trên database thật mới có một entity là Product, còn category chỉ là một cột String. Nhưng dữ liệu catalogue vốn liên kết với nhau: product thuộc về một category và gắn nhiều tag, order gồm nhiều line mà mỗi line trỏ tới một product, còn customer có đúng một profile. JPA map mỗi liên kết như vậy bằng một trong bốn annotation @ManyToOne, @OneToMany, @OneToOne và @ManyToMany, và annotation nào cũng phải trả lời cùng một nhóm câu hỏi về foreign key: nó nằm ở bảng nào, field Java nào ghi nó, cái gì được load theo nó, và cái gì bị lưu hay bị xóa theo nó.
Bài này biến cột category thành entity Category, thêm tag, order có line và customer profile, rồi đọc schema mà Hibernate sinh ra cho từng relationship trên PostgreSQL. Sau đó bài chạy lại những lỗi chỉ lộ ra lúc runtime: order line được lưu với foreign key null, xóa một tag mà các row bị xóa rồi insert lại, cascade xóa luôn một category, collection lazy bị đọc sau khi persistence context đã đóng, chuỗi SELECT N+1 phía sau một endpoint danh sách, và một entity mà Jackson không ghi xong được.
![]()
Các ví dụ dùng Spring Boot 4.1.1 và Java 21, trên một project Initializr có các dependency web, validation, data-jpa, h2 và postgresql. Định nghĩa bảng lấy từ PostgreSQL 18 chạy trong Docker; log SQL, exception và HTTP response lấy từ H2, trừ khi block ghi rõ khác đi. Ứng dụng chạy ở port 8128 thay vì 8080 mặc định.
Từ cột category đến các entity có quan hệ
Catalogue cần sáu relationship, và cả bốn annotation đều được dùng tới:
| Trong catalogue | Cardinality | Map bằng |
|---|---|---|
| Nhiều product thuộc một category | N:1 | @ManyToOne trên Product.category |
| Nhiều product gắn nhiều tag | N:N | @ManyToMany trên Product.tags |
| Một order có nhiều line | 1:N | @OneToMany trên Order.lines, @ManyToOne trên OrderLine.order |
| Nhiều line trỏ tới một product | N:1 | @ManyToOne trên OrderLine.product |
| Nhiều order thuộc một customer | N:1 | @ManyToOne trên Order.customer |
| Một customer có một profile | 1:1 | @OneToOne trên CustomerProfile.customer và Customer.profile |
Các class nằm trong feature package từ bài 21: Category, Tag và Product ở product, Order và OrderLine ở order, cùng package mới customer cho Customer và CustomerProfile. Mỗi entity có một interface JpaRepository trong package của nó, như ở bài 26. Phiên bản đầu tiên này để mọi annotation ở giá trị mặc định. Phần còn lại của bài lần lượt đổi từng thứ một, và thay đổi nào cũng đi kèm SQL cho thấy vì sao cần đổi.
Category và @ManyToOne trên Product
Category là một entity bình thường với tên không được trùng:
package com.example.demo.product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
protected Category() {
}
public Category(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}Trong Product, cột String trở thành reference tới một Category, và có thêm một list tag. Product ở đây là bản rút gọn của bài 26, không có status và khai báo cột đơn giản hơn, để DDL được sinh ra ngắn gọn:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
private String category;
@ManyToOne
@JoinColumn(name = "category_id")
private Category category;
@ManyToMany
@JoinTable(name = "product_tags",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private List<Tag> tags = new ArrayList<>();
protected Product() {
}
public Product(String name, String sku, BigDecimal price, int stock, String category) {
public Product(String name, String sku, BigDecimal price, int stock, Category category) {
this.name = name;
this.sku = sku;
this.price = price;
this.stock = stock;
this.category = category;
}
// getters for id, name, sku, price and stock, and setStock(int)
public String getCategory() {
public Category getCategory() {
return category;
}
public List<Tag> getTags() {
return tags;
}
}@ManyToOnenói rằng nhiều product trỏ tới một category. Field giữ một objectCategory; còn bảng sẽ giữ id của nó.@JoinColumn(name = "category_id")đặt tên cho cột foreign key đó, để schema không phụ thuộc vào quy tắc đặt tên mặc định.@ManyToManycùng@JoinTableđặt tên cho bảng nối product với tag, và cho hai cột của bảng đó.Tagkhông map chiều ngược lại.- Tag được giữ trong một
List, type mà phần lớn code chọn đầu tiên. PhầnSethayListcho thấy lựa chọn đó tốn gì.
Tag có cùng hình dạng với Category:
package com.example.demo.product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
protected Tag() {
}
public Tag(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}Order, line và customer profile
Order thuộc về một customer và sở hữu các line của nó. ORDER là keyword của SQL, nên bảng được đặt tên orders:
package com.example.demo.order;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import com.example.demo.customer.Customer;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {
}
public Order(Customer customer) {
this.customer = customer;
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public Long getId() {
return id;
}
public Customer getCustomer() {
return customer;
}
public List<OrderLine> getLines() {
return lines;
}
}OrderLine giữ cả hai foreign key của một line: tới order và tới product. unitPrice sao chép giá của product lúc tạo line, để việc đổi giá sau này không viết lại các order cũ:
package com.example.demo.order;
import java.math.BigDecimal;
import com.example.demo.product.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal unitPrice;
protected OrderLine() {
}
public OrderLine(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice();
}
public BigDecimal lineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
void setOrder(Order order) {
this.order = order;
}
// getters for id, order, product, quantity and unitPrice
}Relationship của order được map từ cả hai đầu: OrderLine.order với @JoinColumn, và Order.lines với mappedBy = "order", chỉ tới field ở phía bên kia. cascade = CascadeType.ALL khiến việc lưu order lưu luôn các line; phần cascade cho thấy chính xác nó làm gì.
Profile là một bảng riêng, mỗi customer một row:
package com.example.demo.customer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "customer_profiles")
public class CustomerProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(nullable = false)
private String fullName;
private String phone;
protected CustomerProfile() {
}
public CustomerProfile(String fullName, String phone) {
this.fullName = fullName;
this.phone = phone;
}
void setCustomer(Customer customer) {
this.customer = customer;
}
// getters for id, customer, fullName and phone
}package com.example.demo.customer;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL)
private CustomerProfile profile;
protected Customer() {
}
public Customer(String email) {
this.email = email;
}
public void setProfile(CustomerProfile profile) {
this.profile = profile;
profile.setCustomer(this);
}
// getters for id, email and profile
}Customer.setProfile gán field trên cả hai object. Phần owning side và inverse side giải thích vì sao.
Chạy catalogue trên PostgreSQL và H2
spring.application.name=demo
logging.level.org.hibernate.SQL=debugspring.datasource.url=jdbc:postgresql://localhost:55428/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=createKhi không có datasource URL, Spring Boot khởi động một database H2 in-memory và Hibernate tạo bảng lúc startup. Profile postgres trỏ sang PostgreSQL 18 trong Docker, còn ddl-auto=create drop rồi tạo lại các bảng đã map ở mỗi lần khởi động, chỉ phù hợp để đọc DDL được sinh ra; bài 31 thay nó bằng migration Flyway.
docker run -d --name sb-a28-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55428:5432 postgres:18./gradlew -q bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8128Với các lần chạy trên H2, một runner lúc startup luôn seed cùng một bộ dữ liệu: ba category (Keyboards, Mice, Accessories), năm tag, bốn product, ba customer có profile và mười order, mỗi order hai line. Product 1 là bàn phím cơ KB-01, gắn tag mechanical, rgb và bestseller. Các đoạn code gọi thẳng repository chạy trong một runner dùng tạm, mỗi đoạn trong một transaction, trừ khi bài ghi khác; bản thân transaction là chủ đề của bài 30.
Mỗi annotation quan hệ tạo ra gì trong database
Với profile postgres, Hibernate ghi DDL ra log qua org.hibernate.SQL, còn psql trong container cho thấy PostgreSQL đã tạo ra những gì:
docker exec sb-a28-pg psql -U shop -d shop -c '\d products'
@ManyToOne: một cột foreign key
DDL của products, lấy từ log và xuống dòng theo từng cột:
create table products (
price numeric(12,2) not null,
stock integer not null,
category_id bigint,
id bigint generated by default as identity,
name varchar(255) not null,
sku varchar(255) not null unique,
primary key (id)
)
alter table if exists products add constraint FKog2rp4qthbtt2lfyhfo32lsw9 foreign key (category_id) references categories Table "public.products"
Column | Type | Collation | Nullable | Default
-------------+------------------------+-----------+----------+----------------------------------
price | numeric(12,2) | | not null |
stock | integer | | not null |
category_id | bigint | | |
id | bigint | | not null | generated by default as identity
name | character varying(255) | | not null |
sku | character varying(255) | | not null |
Indexes:
"products_pkey" PRIMARY KEY, btree (id)
"products_sku_key" UNIQUE CONSTRAINT, btree (sku)
Foreign-key constraints:
"fkog2rp4qthbtt2lfyhfo32lsw9" FOREIGN KEY (category_id) REFERENCES categories(id)
Referenced by:
TABLE "product_tags" CONSTRAINT "fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
TABLE "order_lines" CONSTRAINT "fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)Foreign key nằm ở phía many: mỗi row product lưu id của category, còn categories không có cột nào trỏ ngược lại. category_id không có not null, vì @ManyToOne mặc định là optional. Hibernate cũng liệt kê các cột theo thứ tự riêng của nó, không theo thứ tự field. Ba field @ManyToOne còn lại cho ra cùng hình dạng: orders.customer_id, order_lines.order_id và order_lines.product_id, mỗi cột có foreign key riêng.
@OneToOne: foreign key kèm UNIQUE constraint
create table customer_profiles (
customer_id bigint unique,
id bigint generated by default as identity,
full_name varchar(255) not null,
phone varchar(255),
primary key (id)
)
alter table if exists customer_profiles add constraint FKnjy1dugyof18fa21tu22fu98j foreign key (customer_id) references customers Table "public.customer_profiles"
Column | Type | Collation | Nullable | Default
-------------+------------------------+-----------+----------+----------------------------------
customer_id | bigint | | |
id | bigint | | not null | generated by default as identity
full_name | character varying(255) | | not null |
phone | character varying(255) | | |
Indexes:
"customer_profiles_pkey" PRIMARY KEY, btree (id)
"customer_profiles_customer_id_key" UNIQUE CONSTRAINT, btree (customer_id)
Foreign-key constraints:
"fknjy1dugyof18fa21tu22fu98j" FOREIGN KEY (customer_id) REFERENCES customers(id)@OneToOne là một cột giống của @ManyToOne cộng thêm UNIQUE constraint, và Hibernate 7.4 tự thêm unique: mapping chỉ ghi @OneToOne và @JoinColumn(name = "customer_id"). Chính constraint đó làm relationship thành one-to-one ở mức database. Phía mappedBy không thêm gì vào bảng của nó:
Table "public.customers"
Column | Type | Collation | Nullable | Default
--------+------------------------+-----------+----------+----------------------------------
id | bigint | | not null | generated by default as identity
email | character varying(255) | | not null |
Indexes:
"customers_pkey" PRIMARY KEY, btree (id)
"customers_email_key" UNIQUE CONSTRAINT, btree (email)
Referenced by:
TABLE "customer_profiles" CONSTRAINT "fknjy1dugyof18fa21tu22fu98j" FOREIGN KEY (customer_id) REFERENCES customers(id)
TABLE "orders" CONSTRAINT "fkpxtb8awmi0dk6smoh2vp1litg" FOREIGN KEY (customer_id) REFERENCES customers(id)Profile cũng có thể dùng chung primary key với customer thay vì có cột foreign key riêng, bằng @MapsId; series này không dùng cách đó.
@ManyToMany: một join table
create table product_tags (
product_id bigint not null,
tag_id bigint not null
)
alter table if exists product_tags add constraint FKpur2885qb9ae6fiquu77tcv1o foreign key (tag_id) references tags
alter table if exists product_tags add constraint FK5rk6s19k3risy7q7wqdr41uss foreign key (product_id) references products Table "public.product_tags"
Column | Type | Collation | Nullable | Default
------------+--------+-----------+----------+---------
product_id | bigint | | not null |
tag_id | bigint | | not null |
Foreign-key constraints:
"fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
"fkpur2885qb9ae6fiquu77tcv1o" FOREIGN KEY (tag_id) REFERENCES tags(id)Cả products lẫn tags đều không có thêm cột nào. Liên kết là một row trong product_tags cho mỗi cặp product và tag, với foreign key tới từng phía. Với List, bảng này không có primary key và không có gì ngăn một cặp bị lưu hai lần; phần Set hay List sẽ quay lại chuyện này.
@OneToMany không có mappedBy: join table ngoài ý muốn
Một cách map hay gặp ở lần thử đầu là chỉ map collection, không có field nào trên line:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@OneToMany(cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>(); @ManyToOne
@JoinColumn(name = "order_id")
private Order order; Hibernate 7.4 không đặt foreign key lên order_lines. Nó tạo ra bảng thứ chín:
List of tables
Schema | Name | Type | Owner
--------+-------------------+-------+-------
public | categories | table | shop
public | customer_profiles | table | shop
public | customers | table | shop
public | order_lines | table | shop
public | orders | table | shop
public | orders_lines | table | shop
public | product_tags | table | shop
public | products | table | shop
public | tags | table | shop
(9 rows)create table orders_lines (
lines_id bigint not null unique,
order_id bigint not null
)
alter table if exists orders_lines add constraint FK6odyxaaebqswyy8yhp9wtfsn4 foreign key (lines_id) references order_lines
alter table if exists orders_lines add constraint FKahmp9mk9umvxbsm32o7m2pt5q foreign key (order_id) references orders Table "public.order_lines"
Column | Type | Collation | Nullable | Default
------------+---------------+-----------+----------+----------------------------------
quantity | integer | | not null |
unit_price | numeric(12,2) | | not null |
id | bigint | | not null | generated by default as identity
product_id | bigint | | |
Indexes:
"order_lines_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
"fk5v1oeejtgtf2n3toppm3tkuhh" FOREIGN KEY (product_id) REFERENCES products(id)
Referenced by:
TABLE "orders_lines" CONSTRAINT "fk6odyxaaebqswyy8yhp9wtfsn4" FOREIGN KEY (lines_id) REFERENCES order_lines(id)Tên bảng ghép từ bảng sở hữu và tên field, orders_lines, với hai cột order_id và lines_id; unique trên lines_id giữ mỗi line ở trong đúng một order. Lưu một order có hai line, quay lại H2:
Order order = new Order(customer);
order.getLines().add(new OrderLine(keyboard, 1));
order.getLines().add(new OrderLine(mouse, 2));
orderRepository.save(order);insert into orders (customer_id,id) values (?,default)
insert into order_lines (product_id,quantity,unit_price,id) values (?,?,?,default)
insert into order_lines (product_id,quantity,unit_price,id) values (?,?,?,default)
insert into orders_lines (order_id,lines_id) values (?,?)
insert into orders_lines (order_id,lines_id) values (?,?)Năm INSERT: ba câu đầu chạy khi save được gọi, hai row của join table chạy lúc commit. Mỗi order giờ tốn thêm một row cho mỗi line, trong một bảng mà model không hề nhắc tới. Với mappedBy như mapping ở đầu bài, cùng order đó chỉ cần ba INSERT, như phần tiếp theo cho thấy.
Owning side và inverse side trong relationship hai chiều
Một relationship hai chiều mô tả một cột foreign key bằng hai field Java: OrderLine.order và Order.lines đều nói line thuộc order nào. Bảng chỉ có một cột order_id, nên JPA ghi nó từ một trong hai field. Field đó là owning side: phía có @JoinColumn, và với cặp @OneToMany và @ManyToOne thì luôn là @ManyToOne. Field có mappedBy là inverse side. Hibernate dùng nó để đọc relationship và bỏ qua nó khi ghi.
Chỉ thêm line vào order.getLines()
Với mapping ở đầu bài, tức có cascade = CascadeType.ALL và order_id cho phép null:
Order order = new Order(customer);
OrderLine line = new OrderLine(keyboard, 1);
order.getLines().add(line);
orderRepository.save(order);insert into orders (customer_id,id) values (?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)Cả hai row đều được ghi và không có lỗi nào. Sau khi commit, một query JdbcTemplate đọc lại line vừa tạo:
jdbcTemplate.queryForList("select id, order_id, product_id, quantity from order_lines where id = (select max(id) from order_lines)");[{ID=23, ORDER_ID=null, PRODUCT_ID=1, QUANTITY=1}]Load lại order 12 trong một transaction mới và gọi getLines().size() thì in ra lines of order 12: 0. Cascade đã insert line vì nó nằm trong collection, nhưng câu INSERT lấy order_id từ OrderLine.order, field mà không ai gán. Order và line đều có trong database nhưng không nối với nhau.
Khi join column có nullable = false, điều mà phần tiếp theo thêm vào, bốn dòng code đó lỗi ngay trong save:
org.springframework.dao.DataIntegrityViolationException: could not execute statement [NULL not allowed for column "ORDER_ID"; SQL statement:
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default) [23502-240]] [insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)]; SQL [insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)]; constraint [ORDER_ID]Thứ vừa chặn lại là constraint của database: chuỗi exception kết thúc ở JdbcSQLIntegrityConstraintViolationException của H2. Một lỗi vẫn tốt hơn một row bị tách rời, nhưng cả hai đều không phải kết quả mong muốn.
Helper method giữ hai phía đồng bộ
Cách sửa là làm cho collection không thể bị đổi mà thiếu owning field. Order có thêm hai method cập nhật cả hai phía cùng lúc:
public Order(Customer customer) {
this.customer = customer;
}
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void removeLine(OrderLine line) {
lines.remove(line);
line.setOrder(null);
}
public BigDecimal total() {OrderLine.setOrder là package-private, nên code bên ngoài com.example.demo.order không thể gán một phía mà bỏ quên phía kia. Customer.setProfile theo đúng pattern đó cho @OneToOne. Cùng order hai line, lần này đi qua helper:
Order order = new Order(customer);
order.addLine(new OrderLine(keyboard, 1));
order.addLine(new OrderLine(mouse, 2));
orderRepository.save(order);insert into orders (customer_id,id) values (?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)Ba INSERT, lần này có bind order_id, so với năm INSERT của mapping một chiều.

@ManyToOne đúng cách: LAZY, optional = false và cột NOT NULL
Mặc định của @ManyToOne là fetch = FetchType.EAGER và optional = true. Cái đầu load nhiều hơn request cần; cái sau cho phép đúng loại row bị tách rời ở phần trước.
Fetch EAGER mặc định load những gì
productRepository.findById(1L), với Product.category để mặc định:
select p1_0.id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 left join categories c1_0 on c1_0.id=p1_0.category_id where p1_0.id=?productRepository.findAll() thì không join. Nó chạy query lấy product, rồi thêm một SELECT cho mỗi category khác nhau để thỏa EAGER:
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?Các association eager còn nối tiếp nhau. Load một order rồi đọc các line của nó, với mọi @ManyToOne và @OneToOne để mặc định:
select o1_0.id,c1_0.id,c1_0.email,p1_0.id,p1_0.full_name,p1_0.phone from orders o1_0 left join customers c1_0 on c1_0.id=o1_0.customer_id left join customer_profiles p1_0 on c1_0.id=p1_0.customer_id where o1_0.id=?
select l1_0.order_id,l1_0.id,p1_0.id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,l1_0.quantity,l1_0.unit_price from order_lines l1_0 left join products p1_0 on p1_0.id=l1_0.product_id left join categories c1_0 on c1_0.id=p1_0.category_id where l1_0.order_id=?Order kéo theo customer và profile của customer; các line kéo theo product và category của từng product. Code load order không hề yêu cầu những thứ đó.
LAZY, optional = false và nullable = false
@ManyToOne
@JoinColumn(name = "category_id")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category; @ManyToOne
@JoinColumn(name = "order_id")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne
@JoinColumn(name = "product_id")
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;Order.customer cũng nhận hai thay đổi đó. @OneToOne nhận cùng các attribute, nên CustomerProfile.customer cũng được đổi, còn phía mappedBy là Customer.profile được thêm fetch = FetchType.LAZY; phần fetch type sẽ kiểm tra thay đổi cuối cùng này có tác dụng gì:
@OneToOne
@JoinColumn(name = "customer_id")
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer; @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL)
@OneToOne(mappedBy = "customer", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private CustomerProfile profile;Mỗi thiết lập có việc riêng:
fetch = FetchType.LAZYload category khi nó được dùng lần đầu, thay vì load cùng mọi product.optional = falsekhai báo trong mapping rằng product nào cũng có category.nullable = falselà thứ đi xuống schema. Trên PostgreSQL,category_idđã thànhnot null:
category_id | bigint | | not null |Vẫn findById(1L), rồi product.getCategory().getName():
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?Câu đầu không còn đụng tới categories; câu thứ hai chỉ chạy khi getName() được gọi. findAll() còn đúng một statement select ... from products p1_0, không còn SELECT cho từng category.
Set hay List cho @ManyToMany
List không có index column là thứ Hibernate gọi là bag: một collection không có thứ tự và có thể chứa phần tử trùng. Join table của bag không có primary key, như \d product_tags đã cho thấy, nên Hibernate không có cách nào nhắm vào đúng một row trong đó.
Xóa một tag khỏi List
Product 1 có các tag [mechanical, rgb, bestseller]. Xóa rgb bên trong một transaction:
Product keyboard = productRepository.findById(1L).orElseThrow();
Tag rgb = tagRepository.findByName("rgb").orElseThrow();
keyboard.getTags().remove(rgb);SQL lúc flush:
delete from product_tags where product_id=?
insert into product_tags (product_id,tag_id) values (?,?)
insert into product_tags (product_id,tag_id) values (?,?)Hibernate xóa mọi row của product 1 rồi insert lại hai tag còn lại. Khối lượng công việc phụ thuộc vào số tag mà product còn giữ, không phải vào tag vừa bị xóa.
Xóa một tag khỏi Set
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
// ...
@ManyToMany
@JoinTable(name = "product_tags",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private List<Tag> tags = new ArrayList<>();
private Set<Tag> tags = new HashSet<>();
// ...
public List<Tag> getTags() {
public Set<Tag> getTags() {
return tags;
}Vẫn ba dòng code đó, và lúc flush:
delete from product_tags where product_id=? and tag_id=?Một statement cho một tag bị xóa. Schema cũng đổi: với Set, Hibernate khai báo cả hai cột làm primary key, nên chính database sẽ từ chối một cặp trùng:
Table "public.product_tags"
Column | Type | Collation | Nullable | Default
------------+--------+-----------+----------+---------
product_id | bigint | | not null |
tag_id | bigint | | not null |
Indexes:
"product_tags_pkey" PRIMARY KEY, btree (product_id, tag_id)
Foreign-key constraints:
"fk5rk6s19k3risy7q7wqdr41uss" FOREIGN KEY (product_id) REFERENCES products(id)
"fkpur2885qb9ae6fiquu77tcv1o" FOREIGN KEY (tag_id) REFERENCES tags(id)Tag không override equals và hashCode. Trong một persistence context, mỗi row được biểu diễn bằng đúng một object Java, nên rgb mà tagRepository trả về chính là object đang nằm trong set, và remove tìm thấy nó. Một HashSet trộn các entity được load ở những persistence context khác nhau thì cần một trong hai cách viết equals và hashCode của bài 26, dựa trên id hoặc trên một natural key như tên tag vốn là unique.
Order.lines vẫn là List. Với mappedBy, mỗi line là một row có id riêng, và xóa một line thì xóa đúng row đó, như phần orphanRemoval cho thấy.
MultipleBagFetchException khi có hai collection List
Bag còn một cái giá thứ hai. Vẫn với List<Tag>, một repository method fetch các line, product của chúng và tag của các product trong cùng một query:
@Query("select o from Order o left join fetch o.lines l left join fetch l.product p left join fetch p.tags")
List<Order> findAllWithLinesAndTags();Ứng dụng khởi động bình thường. Gọi method thì ném ra:
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.demo.product.Product.tags, com.example.demo.order.Order.lines]Một phép join SQL trên hai collection sẽ nhân số row của chúng, và bag không phân biệt được bản sao do join sinh ra với phần tử trùng thật sự nằm trong collection, nên Hibernate từ chối dựng kết quả. Khi tags là Set, cùng câu JPQL đó chạy thành một statement duy nhất:
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,t1_0.product_id,t1_1.id,t1_1.name,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id left join products p1_0 on p1_0.id=l1_0.product_id left join product_tags t1_0 on p1_0.id=t1_0.product_id left join tags t1_1 on t1_1.id=t1_0.tag_idCác loại cascade và orphanRemoval
Cascade lặp lại một thao tác của EntityManager trên parent cho các entity nằm trong một association của nó. Mỗi loại mang tên thao tác mà nó lặp lại:
| Loại cascade | Lặp lại trên các entity liên kết |
|---|---|
PERSIST | persist: parent mới làm các child mới trở thành persistent |
MERGE | merge: chép một parent đã detach trở lại persistence context thì chép luôn các child |
REMOVE | remove: xóa parent thì xóa các child |
REFRESH, DETACH | load lại parent từ database, hoặc detach nó |
ALL | tất cả các loại trên |
save của Spring Data quyết định thao tác nào sẽ chạy, như bài 26 đã cho thấy: SimpleJpaRepository.save gọi EntityManager.persist với entity mới và EntityManager.merge với mọi entity còn lại. Vì vậy một order mới cần PERSIST để mang các line theo, còn một order được save lại sau khi đã detach thì cần MERGE. ALL bao cả hai, và kéo theo cả REMOVE.
Lưu order cùng các line bằng một lần save
OrderService.place dựng order qua helper method và gọi save đúng một lần. Rule về stock lấy từ bài 21, giờ áp dụng trên entity, và method chạy trong một transaction:
package com.example.demo.order;
import java.util.List;
import com.example.demo.customer.Customer;
import com.example.demo.customer.CustomerNotFoundException;
import com.example.demo.customer.CustomerRepository;
import com.example.demo.product.Product;
import com.example.demo.product.ProductService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orders;
private final CustomerRepository customers;
private final ProductService productService;
public OrderService(OrderRepository orders, CustomerRepository customers, ProductService productService) {
this.orders = orders;
this.customers = customers;
this.productService = productService;
}
@Transactional
public Order place(Long customerId, List<OrderItem> items) {
Customer customer = customers.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
Order order = new Order(customer);
for (OrderItem item : items) {
Product product = productService.reserveStock(item.productId(), item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
}
return orders.save(order);
}
@Transactional(readOnly = true)
public Order findById(Long id) {
return orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
@Transactional(readOnly = true)
public List<Order> findAll() {
return orders.findAll();
}
}package com.example.demo.order;
public record OrderItem(Long productId, int quantity) {
} public Product reserveStock(Long id, int quantity) {
Product product = findById(id);
if (product.getStock() < quantity) {
throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
}
product.setStock(product.getStock() - quantity);
return product;
}SQL của một request POST /api/customers/1/orders có hai line; controller đứng sau nó nằm ở phần fetch type:
select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
insert into orders (customer_id,id) values (?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)
insert into order_lines (order_id,product_id,quantity,unit_price,id) values (?,?,?,?,default)
update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?Một lần save đã ghi order và cả hai line: PERSIST lan tới các line vì addLine đã đưa chúng vào collection. Hai câu UPDATE là thay đổi stock, được dirty checking phát hiện lúc commit. Câu SELECT trên customer_profiles được giải thích ở phần fetch type. Khi bỏ cascade:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@OneToMany(mappedBy = "order")
private List<OrderLine> lines = new ArrayList<>();cũng các lời gọi addLine đó rồi orderRepository.save(order) chỉ sinh ra một INSERT:
insert into orders (customer_id,id) values (?,default)Không có exception, và transaction commit bình thường. Các line nằm trong collection và đã được gán order, nhưng không có gì bảo Hibernate persist chúng, nên chúng không bao giờ tới được database.
Xóa một line khi có và không có orphanRemoval
Đặt lại cascade = CascadeType.ALL, rồi xóa line đầu tiên của order 1 bên trong một transaction:
Order order = orderRepository.findById(1L).orElseThrow();
OrderLine first = order.getLines().get(0);
order.removeLine(first);
entityManager.flush();update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=?org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "ORDER_ID"; SQL statement:
update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=? [23502-240]] [update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=?]removeLine gán OrderLine.order về null, và thay đổi ở owning side là một câu UPDATE lên cột foreign key, bị NOT NULL từ chối. Với order_id cho phép null của mapping đầu tiên, cùng câu UPDATE đó chạy thành công và line ở lại trong order_lines mà không thuộc order nào. orphanRemoval = true khai báo rằng một line bị lấy khỏi collection thì không còn lý do để tồn tại:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();delete from order_lines where id=?Order 1 có hai line, và sau đó select count(*) from order_lines where order_id = 1 trả về 1. orphanRemoval hợp với child chỉ tồn tại như một phần của parent, như order line. Một tag bị gỡ khỏi product là chuyện khác: tag đó vẫn tồn tại cho các product khác.
Vì sao CascadeType.REMOVE trên @ManyToOne hoặc @ManyToMany nguy hiểm
ALL bao gồm REMOVE, và cascade = CascadeType.ALL hay bị chép lên mọi association:
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@ManyToMany
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "product_tags",Một product mới, Webcam 1080p, thuộc category mới Webcams với tag mới streaming, rồi gọi productRepository.deleteById(webcamId):
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
select t1_0.product_id,t1_1.id,t1_1.name from product_tags t1_0 join tags t1_1 on t1_1.id=t1_0.tag_id where t1_0.product_id=?
select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
delete from product_tags where product_id=?
delete from tags where id=?
delete from products where id=?
delete from categories where id=?Tên category và tag, đọc bằng JdbcTemplate trước khi xóa:
categories: [Keyboards, Mice, Accessories, Webcams]
tags: [wireless, bestseller, usb-c, rgb, mechanical, streaming]và sau khi xóa:
categories: [Keyboards, Mice, Accessories]
tags: [wireless, bestseller, usb-c, rgb, mechanical]Xóa một product đã xóa luôn một category và một tag, không một lời cảnh báo, vì không còn row nào khác reference tới chúng. Cùng thao tác xóa trên Gaming mouse, thuộc category dùng chung Mice và gắn tag dùng chung wireless, thì đi được tới tag. Các câu DELETE của nó, rồi tới exception:
delete from product_tags where product_id=?
delete from tags where id=?org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referential integrity constraint violation: "FKPUR2885QB9AE6FIQUU77TCV1O: PUBLIC.PRODUCT_TAGS FOREIGN KEY(TAG_ID) REFERENCES PUBLIC.TAGS(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from tags where id=? [23503-240]] [delete from tags where id=?]; SQL [delete from tags where id=?]; constraint [FKPUR2885QB9AE6FIQUU77TCV1O]Vậy kết quả phụ thuộc vào dữ liệu: thao tác xóa hoặc lấy mất những row mà phần còn lại của catalogue vẫn cần, hoặc lỗi ở một foreign key. Category hay tag có vòng đời riêng, và cascade từ phía many sang phía one, hoặc qua @ManyToMany, sẽ xóa nó cùng với product đầu tiên bị xóa. Không có hai cascade đó, xóa webcam chỉ chạy:
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
delete from product_tags where product_id=?
delete from products where id=?và cả hai danh sách vẫn kết thúc bằng Webcams và streaming.
FetchType mặc định và LazyInitializationException
Fetch type mặc định của từng annotation
Jakarta Persistence cho association trỏ tới một entity mặc định là eager, còn collection mặc định là lazy:
| Annotation | fetch mặc định | Nó đã làm gì trong catalogue này |
|---|---|---|
@ManyToOne | EAGER | findById join categories; findAll() thêm một SELECT cho mỗi category khác nhau |
@OneToOne | EAGER | customerRepository.findAll() thêm một SELECT cho mỗi customer để lấy profile |
@OneToMany | LAZY | order.getLines() chạy SELECT riêng ở lần dùng đầu tiên |
@ManyToMany | LAZY | product.getTags() chạy SELECT riêng ở lần dùng đầu tiên |
Hàng @OneToOne lấy từ customerRepository.findAll() với mapping đầu tiên:
select c1_0.id,c1_0.email from customers c1_0
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?
select cp1_0.id,c1_0.id,c1_0.email,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 left join customers c1_0 on c1_0.id=cp1_0.customer_id where cp1_0.customer_id=?Phần @ManyToOne đã đặt fetch = FetchType.LAZY lên cả hai phía của relationship này. Ở Customer.profile, phía mappedBy, nó không làm profile thành lazy: customerRepository.findById(1L) chạy hai statement trước khi có code nào đụng tới profile,
select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?và findAll() vẫn chạy thêm một SELECT cho mỗi customer. Row trong customers không có cột nào cho biết profile có tồn tại hay không, nên Hibernate phải query customer_profiles để chọn giữa null và một lazy reference. Đó chính là câu SELECT trong request POST ở phần cascade. Khi profile hiếm khi cần đi cùng customer, hãy map relationship chỉ ở CustomerProfile và tìm profile theo customer id.
Các endpoint của order
Các endpoint dùng URL từ bài 15 và map entity sang DTO trong controller, như bài 21 đã chốt. Request mang một danh sách line, còn response lồng một record cho mỗi line:
package com.example.demo.order;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record PlaceOrderRequest(@NotEmpty List<@Valid Line> lines) {
public record Line(@NotNull Long productId, @NotNull @Positive Integer quantity) {
}
}package com.example.demo.order;
import java.math.BigDecimal;
import java.util.List;
public record OrderResponse(Long id, Long customerId, List<OrderLineResponse> lines, BigDecimal total) {
public record OrderLineResponse(Long productId, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) {
static OrderLineResponse from(OrderLine line) {
return new OrderLineResponse(line.getProduct().getId(), line.getQuantity(),
line.getUnitPrice(), line.lineTotal());
}
}
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getCustomer().getId(),
order.getLines().stream().map(OrderLineResponse::from).toList(), order.total());
}
}package com.example.demo.order;
import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
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.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping("/api/customers/{customerId}/orders")
public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
@Valid @RequestBody PlaceOrderRequest request) {
List<OrderItem> items = request.lines().stream()
.map(line -> new OrderItem(line.productId(), line.quantity()))
.toList();
Order order = service.place(customerId, items);
URI location = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/orders/{id}")
.buildAndExpand(order.getId())
.toUri();
return ResponseEntity.created(location).body(OrderResponse.from(order));
}
@GetMapping("/api/orders/{id}")
public OrderResponse findById(@PathVariable Long id) {
return OrderResponse.from(service.findById(id));
}
@GetMapping("/api/orders")
public List<OrderResponse> findAll() {
return service.findAll().stream().map(OrderResponse::from).toList();
}
}OrderResponse.from chạy trong controller, sau khi OrderService.findById đã return, và nó đọc order.getLines().
LazyInitializationException khi open-in-view=false
Bài 26 đã tắt spring.jpa.open-in-view và nêu LazyInitializationException là lý do. Phần này và phần sau chạy endpoint của order khi thiết lập đó tắt và khi để giá trị mặc định true, để thấy nó thay đổi điều gì. Khởi động với --spring.jpa.open-in-view=false:
curl -s -i http://localhost:8128/api/orders/1HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:15 GMT
Connection: close
{"timestamp":"2026-09-13T09:25:15.863Z","status":500,"error":"Internal Server Error","path":"/api/orders/1"}Log, chạy với logging.level.org.springframework.orm.jpa=debug và logging.level.org.springframework.web.servlet.DispatcherServlet=debug, lọc còn các dòng liên quan và bỏ timestamp:
o.s.web.servlet.DispatcherServlet : GET "/api/orders/1", parameters={}
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
o.s.orm.jpa.JpaTransactionManager : Opened new EntityManager [SessionImpl(651741364<open>)] for JPA transaction
org.hibernate.SQL : select o1_0.id,o1_0.customer_id from orders o1_0 where o1_0.id=?
o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(651741364<open>)]
o.s.orm.jpa.JpaTransactionManager : Closing JPA EntityManager after transaction
o.s.web.servlet.DispatcherServlet : Failed to complete request: org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session)
at org.hibernate.collection.spi.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:664) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
at org.hibernate.collection.spi.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:239) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
at org.hibernate.collection.spi.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:624) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
at org.hibernate.collection.spi.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
at org.hibernate.collection.spi.PersistentBag.iterator(PersistentBag.java:419) ~[hibernate-core-7.4.5.Final.jar!/:7.4.5.Final]
at com.example.demo.order.OrderResponse.from(OrderResponse.java:18) ~[!/:0.0.1-SNAPSHOT]
at com.example.demo.order.OrderController.findById(OrderController.java:41) ~[!/:0.0.1-SNAPSHOT]Transaction của findById đóng EntityManager lúc commit, và order rời service khi lines vẫn chưa được load. Dòng 18 của OrderResponse là lần đầu dùng collection, và lúc đó không còn session nào để load nó. Dòng 17 không lỗi: order.getCustomer().getId() đọc id từ lazy reference của customer mà không cần query. Không có @ExceptionHandler nào xử lý exception này, nên cơ chế xử lý lỗi của Spring Boot trả về 500. Lazy loading cần một persistence context còn mở; transaction bắt đầu và kết thúc ở đâu là chủ đề của bài 30.
Điều mà open-in-view=true che giấu
Cùng request đó với thiết lập mặc định trả về 200 và đúng JSON mong đợi. Log cho thấy bằng cách nào:
o.s.web.servlet.DispatcherServlet : GET "/api/orders/1", parameters={}
o.j.s.OpenEntityManagerInViewInterceptor : Opening JPA EntityManager in OpenEntityManagerInViewInterceptor
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
org.hibernate.SQL : select o1_0.id,o1_0.customer_id from orders o1_0 where o1_0.id=?
o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1177722833<open>)]
o.s.orm.jpa.JpaTransactionManager : Not closing pre-bound JPA EntityManager after transaction
org.hibernate.SQL : select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
o.j.s.OpenEntityManagerInViewInterceptor : Closing JPA EntityManager in OpenEntityManagerInViewInterceptor
o.s.web.servlet.DispatcherServlet : Completed 200 OKOpenEntityManagerInViewInterceptor mở một EntityManager trước khi controller chạy và đóng nó khi request kết thúc. Transaction commit, dòng Not closing pre-bound JPA EntityManager after transaction giữ EntityManager đó sống tiếp, và câu SELECT thứ hai chạy từ OrderResponse.from trong controller, sau khi đã commit. Với mặc định thì không có gì lỗi, và mọi lazy association mà web layer chạm tới đều thành một query gửi đi từ controller hoặc từ Jackson. Phần còn lại của bài chạy với spring.jpa.open-in-view=false, đúng như bài 26 để lại.
Fetch lines ngay trong repository
Cách sửa là load những gì endpoint cần trước khi service return:
@EntityGraph(attributePaths = "lines")
Optional<Order> findWithLinesById(Long id); @Transactional(readOnly = true)
public Order findById(Long id) {
return orders.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
}findWithLinesById là derived query từ bài 27, trong đó phần chữ giữa find và By là tùy ý, còn @EntityGraph thêm các line vào query. Với open-in-view=false, request chạy một statement và kết thúc với 200:
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.id=?Vấn đề N+1 query
Một collection lazy chạy đúng vẫn có cái giá khi bị đọc trong vòng lặp. Hình dưới theo dõi Order.lines qua phần này và phần trước.

Mười order, mười một statement SELECT
GET /api/orders với OrderService.findAll gọi orders.findAll(), controller map từng order, và open-in-view để mặc định là true, thiết lập khiến vấn đề này không bị ai để ý:
select o1_0.id,o1_0.customer_id from orders o1_0
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?Giữa hai dòng GET "/api/orders" và Completed 200 OK, log có 11 dòng org.hibernate.SQL: một query cho mười order, rồi một query lấy line cho từng order khi OrderResponse.from đi tới nó. Đó là vấn đề N+1: một query cho danh sách, cộng thêm N query cho một association của từng phần tử. Không có SELECT nào cho product, vì line.getProduct().getId() đọc id từ lazy reference.
Cách sửa 1: JOIN FETCH trong @Query
@Query("select o from Order o left join fetch o.lines order by o.id")
List<Order> findAllWithLines(); @Transactional(readOnly = true)
public List<Order> findAll() {
return orders.findAll();
return orders.findAllWithLines();
}Với open-in-view=false, cùng request đó:
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id order by o1_0.idMột statement. Phép join trả về một row cho mỗi line, tức hai mươi row cho mười order, nhưng mảng JSON vẫn có 10 order: Hibernate 7 tự loại các parent trùng của fetch join, nên query không cần distinct. left join giữ lại những order không có line nào; join fetch thường sẽ bỏ chúng đi.
Cách sửa 2: @EntityGraph
Cùng kết quả mà không phải viết JPQL, bằng cách gắn annotation lên findAll được kế thừa:
package com.example.demo.order;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o left join fetch o.lines order by o.id")
List<Order> findAllWithLines();
@Override
@EntityGraph(attributePaths = "lines")
List<Order> findAll();
@EntityGraph(attributePaths = "lines")
Optional<Order> findWithLinesById(Long id);
}orders.findAll(), map sang cùng các DTO đó:
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_idVẫn một statement, được sinh từ entity graph dưới dạng left join. Điểm khác là nơi áp dụng: @EntityGraph trên findAll có tác dụng với mọi nơi gọi findAll, còn findAllWithLines là method riêng chỉ endpoint danh sách gọi. Batch fetching và phần còn lại của việc tối ưu N+1 thuộc về khóa Advanced.
Trả entity có relationship hai chiều về dạng JSON
Jackson 3 làm gì với entity Order
Một endpoint dùng tạm trả về chính entity, chạy khi OrderService.findById vẫn dùng orders.findById(id) đơn thuần và open-in-view còn để mặc định, để lazy loading có thể xảy ra trong lúc Jackson ghi:
@GetMapping("/lab/orders/{id}/entity")
Order entity(@PathVariable Long id) {
return service.findById(id);
}curl -s -i http://localhost:8128/lab/orders/1/entityHeader và phần đầu của body:
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:12 GMT
{"customer":{"email":"an@example.com","hibernateLazyInitializer":{},"id":1,"profile":{"fullName":"Nguyen Van An","phone":"0901000001","customer":{"email":"an@example.com","hibernateLazyInitializer":{},"id":1,"profile":{"fullName":"Nguyen Van An","phone":"0901000001",Body dài 33,682 byte và không bao giờ tới được các field của chính order. Nó bị gãy ở đây:
"phone":"0901000001","customer":{"email":"an@example.com","hibernateLazyInitializer":}}}}}}}}}}}}}500 ký tự cuối đều là dấu đóng ngoặc nhọn, và json.loads của Python từ chối body với Expecting value: line 1 column 33183 (char 33182). Log của ứng dụng:
.w.s.m.s.DefaultHandlerExceptionResolver : Ignoring exception, response committed already: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500, from `StreamWriteConstraints.getMaxNestingDepth()`)
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500, from `StreamWriteConstraints.getMaxNestingDepth()`)]
o.s.web.servlet.DispatcherServlet : Completed 200 OK- Thứ tự alphabet quyết định đường đi. Jackson 3 ghi property của class theo thứ tự alphabet, như bài 18 đã đo, nên
customerđứng trướcidvàlines. Trong body không có chỗ nào xuất hiện"lines". - Vòng lặp là
Customer.profilevàCustomerProfile.customer."customer"xuất hiện 250 lần và"profile"249 lần. Jackson đi theo cặp đó cho tới khi document sâu 501 cấp;StreamWriteConstraints.defaults().getMaxNestingDepth()trả về 500 trong Jackson 3.1.5. - Status đã được gửi đi. Lúc chạm giới hạn thì response đã commit, nên Spring chỉ có thể log
response committed already. Client nhận 200 kèm JSON không hợp lệ. hibernateLazyInitializerlà của Hibernate.Order.customerlà một lazy proxy, và một getter của class proxy đó bị serialize thành object rỗng. MộtJsonMapper.builder().build()thuần trong Jackson 3.1.5 báoSerializationFeature.FAIL_ON_EMPTY_BEANSđang tắt.
Với open-in-view=false, cùng endpoint đó trả về 500:
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Could not initialize proxy [com.example.demo.customer.Customer#1] - no session]Dù theo cách nào, thứ client nhận được do entity graph và nội tình của Hibernate quyết định chứ không phải một API contract, đúng lập luận mà bài 18 đưa ra để phản đối việc lộ entity.
OrderResponse với DTO lồng cho từng line
OrderResponse cùng OrderLineResponse lồng bên trong, từ phần fetch type, chính là contract đó: chúng chép id và giá trị, nên không có back-reference nào cho Jackson đi theo và không có proxy nào để serialize. Với các repository method có fetch và open-in-view=false:
curl -s -i http://localhost:8128/api/orders/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 178
Date: Sun, 13 Sep 2026 09:25:19 GMT
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":1,"unitPrice":89.90,"lineTotal":89.90},{"productId":2,"quantity":2,"unitPrice":24.50,"lineTotal":49.00}],"total":138.90}Request log đúng một SQL statement, câu left join từ findWithLinesById. Đặt một order qua endpoint POST:
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":2}]}' http://localhost:8128/api/customers/1/ordersHTTP/1.1 201
Location: http://localhost:8128/api/orders/11
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 09:25:19 GMT
{"id":11,"customerId":1,"lines":[{"productId":1,"quantity":1,"unitPrice":89.90,"lineTotal":89.90},{"productId":2,"quantity":2,"unitPrice":24.50,"lineTotal":49.00}],"total":138.90}Sau đó GET /api/orders/11 trả về cùng body đó với Content-Length: 179, đủ cả hai line.
Tóm tắt các annotation quan hệ JPA
| Annotation | Fetch mặc định | Owning side | Schema sinh ra | Thiết lập cho catalogue này |
|---|---|---|---|---|
@ManyToOne | EAGER | Luôn là phía này | Một cột foreign key trong bảng của chính nó, products.category_id | fetch = LAZY, optional = false, @JoinColumn(nullable = false), không cascade |
@OneToMany | LAZY | @ManyToOne của child, qua mappedBy | Không có cột nào khi có mappedBy; một join table như orders_lines khi thiếu nó | mappedBy, dùng List được, cascade = ALL và orphanRemoval = true cho line thuộc về order, helper addLine và removeLine |
@OneToOne | EAGER | Phía có @JoinColumn; phía kia dùng mappedBy | Một cột foreign key kèm UNIQUE constraint, customer_profiles.customer_id | fetch = LAZY, optional = false, nullable = false; phía mappedBy vẫn tốn một SELECT |
@ManyToMany | LAZY | Phía có @JoinTable; phía kia dùng mappedBy | Một join table với hai foreign key, product_tags, có primary key trên cả hai cột khi dùng Set | Set, @JoinTable khai báo rõ, không cascade REMOVE hay ALL |
FAQ
Owning side và inverse side khác nhau thế nào?
Owning side là field mà JPA đọc để ghi foreign key: phía có @JoinColumn hoặc @JoinTable, và luôn là @ManyToOne trong một cặp one-to-many. Inverse side mang mappedBy và bị bỏ qua khi ghi. Chỉ đổi order.getLines() thì line được lưu với order_id NULL; gán OrderLine.order mới là thứ điền vào cột đó.
Vì sao foreign key bị null sau khi lưu collection @OneToMany?
Vì chỉ collection, tức inverse side, bị thay đổi. Câu INSERT lấy foreign key từ @ManyToOne của child, mà field đó vẫn null. Hãy thêm một helper như addLine để gán cả hai phía, và nullable = false để việc quên một phía lỗi với NULL not allowed for column "ORDER_ID" thay vì lưu một row bị tách rời.
@ManyToOne có nên để LAZY?
Với phần lớn ứng dụng thì có. EAGER mặc định join categories vào mọi lần findById product, thêm một SELECT cho mỗi category vào findAll(), và nối tiếp từ order sang customer sang profile. Với LAZY, category chỉ được load khi getName() được gọi, và endpoint nào cần nó thì fetch nó trong query của mình.
Nên dùng Set hay List cho @ManyToMany?
Set. Xóa một tag khỏi List đã xóa mọi row của product trong product_tags rồi insert lại các row còn lại; với Set chỉ còn một câu DELETE, join table có primary key, và fetch join qua hai collection không còn lỗi MultipleBagFetchException. List vẫn ổn cho @OneToMany có mappedBy, vì mỗi row child có id riêng.
Sửa LazyInitializationException trong Spring Boot thế nào?
Load những gì nơi gọi cần trong lúc persistence context còn mở, bằng query JOIN FETCH hoặc @EntityGraph trên repository method, rồi map sang DTO. Trong Hibernate 7.4 thông báo lỗi là Cannot lazily initialize collection of role 'com.example.demo.order.Order.lines' with key '1' (no session). Bật lại open-in-view sẽ giấu exception đi nhưng đẩy các query sang web layer.
JOIN FETCH trong Hibernate 7 còn cần distinct không?
Với fetch join thì không. select o from Order o left join fetch o.lines order by o.id trả về một row cho mỗi line, tức hai mươi row cho mười order, nhưng JSON của GET /api/orders vẫn chỉ có 10 order: Hibernate 7 tự loại các parent trùng của fetch join.
Vì sao trả thẳng JPA entity từ controller làm hỏng JSON?
Jackson đi theo mọi getter, kể cả hai chiều của relationship hai chiều và các class proxy của Hibernate. Với Spring Boot 4.1.1 và Jackson 3.1.5, entity Order đi qua đi lại giữa Customer.profile và CustomerProfile.customer cho tới khi chạm giới hạn lồng 500 cấp, và client nhận 200 kèm 33,682 byte JSON không hợp lệ. Hãy trả về DTO.
Kết luận
Mỗi annotation quan hệ là một quyết định về foreign key. @ManyToOne đặt một cột ở phía many, @OneToOne là cột đó kèm UNIQUE constraint, @ManyToMany cần một join table, còn @OneToMany không có mappedBy tạo ra một join table không ai yêu cầu, tốn năm INSERT cho một order hai line thay vì ba. Trong một cặp hai chiều chỉ owning side được ghi, nên helper method gán cả hai phía, và nullable = false biến việc quên một phía thành lỗi thay vì một row có foreign key NULL.
Với catalogue này, điều đó nghĩa là LAZY và optional = false trên mọi @ManyToOne, Set cho @ManyToMany, cascade = ALL kèm orphanRemoval chỉ ở nơi child thuộc về parent, và không cascade về phía category hay tag, những thứ mà một lần xóa đã lấy mất không một lời cảnh báo. Collection vẫn lazy, nên mỗi endpoint fetch đúng những gì nó map: JOIN FETCH hoặc @EntityGraph đã biến 11 statement thành một và làm open-in-view=false trở nên an toàn. Entity nằm sau DTO, vì không thể giao cho Jackson một graph trỏ ngược về chính nó.
Bài tiếp theo phân trang dữ liệu này: phân trang và sắp xếp với Pageable và Sort, và trả kết quả phân trang qua API.