Command Palette

Search for a command to run...

[Spring Boot Basics] Quan hệ giữa các entity trong JPA: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany, cascade và FetchType

Đế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@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.

Bốn bảng của catalogue là Order, OrderLine, Product và Tag, nối với nhau bằng foreign key vẽ theo kiểu one-to-many, many-to-one và many-to-many

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, h2postgresql. Đị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 catalogueCardinalityMap bằng
Nhiều product thuộc một categoryN:1@ManyToOne trên Product.category
Nhiều product gắn nhiều tagN:N@ManyToMany trên Product.tags
Một order có nhiều line1:N@OneToMany trên Order.lines, @ManyToOne trên OrderLine.order
Nhiều line trỏ tới một productN:1@ManyToOne trên OrderLine.product
Nhiều order thuộc một customerN:1@ManyToOne trên Order.customer
Một customer có một profile1:1@OneToOne trên CustomerProfile.customerCustomer.profile

Các class nằm trong feature package từ bài 21: Category, TagProductproduct, OrderOrderLineorder, cùng package mới customer cho CustomerCustomerProfile. 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:

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

src/main/java/com/example/demo/product/Product.java
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; 
    } 
}
  • @ManyToOne nói rằng nhiều product trỏ tới một category. Field giữ một object Category; 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.
  • @ManyToMany cùng @JoinTable đặt tên cho bảng nối product với tag, và cho hai cột của bảng đó. Tag khô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ần Set hay List cho thấy lựa chọn đó tốn gì.

Tag có cùng hình dạng với Category:

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

src/main/java/com/example/demo/order/Order.java
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ũ:

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

src/main/java/com/example/demo/customer/CustomerProfile.java
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
}
src/main/java/com/example/demo/customer/Customer.java
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

src/main/resources/application.properties
spring.application.name=demo
logging.level.org.hibernate.SQL=debug
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55428/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=create

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

Bash
docker run -d --name sb-a28-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55428:5432 postgres:18
Bash
./gradlew -q bootJar
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --server.port=8128

Vớ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, rgbbestseller. 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ì:

Bash
docker exec sb-a28-pg psql -U shop -d shop -c '\d products'

Bốn hàng, mỗi hàng một annotation: @ManyToOne trên Product.category cạnh cột products.category_id trỏ tới categories, @OneToOne trên CustomerProfile.customer cạnh customer_profiles.customer_id có FK và UNIQUE, @OneToMany(mappedBy) trên Order.lines cạnh order_lines.order_id trỏ tới orders, và @ManyToMany trên Product.tags cạnh join table product_tags với hai foreign key NOT NULL

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

SQL
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
Text
                                    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_idorder_lines.product_id, mỗi cột có foreign key riêng.

@OneToOne: foreign key kèm UNIQUE constraint

SQL
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
Text
                                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@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ó:

Text
                                 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

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

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    @OneToMany(cascade = CascadeType.ALL) 
    private List<OrderLine> lines = new ArrayList<>();
src/main/java/com/example/demo/order/OrderLine.java
    @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:

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

Java
Order order = new Order(customer);
order.getLines().add(new OrderLine(keyboard, 1));
order.getLines().add(new OrderLine(mouse, 2));
orderRepository.save(order);
Text
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.orderOrder.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@ManyToOne thì luôn là @ManyToOne. Field có mappedByinverse 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.ALLorder_id cho phép null:

Java
Order order = new Order(customer);
OrderLine line = new OrderLine(keyboard, 1);
order.getLines().add(line);
orderRepository.save(order);
Text
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:

Java
jdbcTemplate.queryForList("select id, order_id, product_id, quantity from order_lines where id = (select max(id) from order_lines)");
Text
[{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:

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

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

Java
Order order = new Order(customer);
order.addLine(new OrderLine(keyboard, 1));
order.addLine(new OrderLine(mouse, 2));
orderRepository.save(order);
Text
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.

Hai panel. Bên trái: order.getLines().add(line) để OrderLine.order bằng null; khi flush Hibernate đọc OrderLine.order và bỏ qua collection có mappedBy, nên row nhận order_id NULL, load lại order 12 thấy 0 line, và với nullable = false câu insert lỗi NULL not allowed for column ORDER_ID. Bên phải: order.addLine(line) gán cả hai field, ba INSERT, và GET /api/orders/11 trả về đủ hai line

@ManyToOne đúng cách: LAZY, optional = false và cột NOT NULL

Mặc định của @ManyToOnefetch = FetchType.EAGERoptional = 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:

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

Text
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@OneToOne để mặc định:

Text
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

src/main/java/com/example/demo/product/Product.java
    @ManyToOne
    @JoinColumn(name = "category_id") 
    @ManyToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "category_id", nullable = false) 
    private Category category;
src/main/java/com/example/demo/order/OrderLine.java
    @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 mappedByCustomer.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ì:

src/main/java/com/example/demo/customer/CustomerProfile.java
    @OneToOne
    @JoinColumn(name = "customer_id") 
    @OneToOne(fetch = FetchType.LAZY, optional = false) 
    @JoinColumn(name = "customer_id", nullable = false) 
    private Customer customer;
src/main/java/com/example/demo/customer/Customer.java
    @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.LAZY load category khi nó được dùng lần đầu, thay vì load cùng mọi product.
  • optional = false khai báo trong mapping rằng product nào cũng có category.
  • nullable = false là thứ đi xuống schema. Trên PostgreSQL, category_id đã thành not null:
Text
 category_id | bigint                 |           | not null |

Vẫn findById(1L), rồi product.getCategory().getName():

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

Java
Product keyboard = productRepository.findById(1L).orElseThrow();
Tag rgb = tagRepository.findByName("rgb").orElseThrow();
keyboard.getTags().remove(rgb);

SQL lúc flush:

Text
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

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

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

Text
             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 equalshashCode. Trong một persistence context, mỗi row được biểu diễn bằng đúng một object Java, nên rgbtagRepository 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 equalshashCode 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:

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

Text
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 tagsSet, cùng câu JPQL đó chạy thành một statement duy nhất:

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

Cá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 cascadeLặp lại trên các entity liên kết
PERSISTpersist: parent mới làm các child mới trở thành persistent
MERGEmerge: chép một parent đã detach trở lại persistence context thì chép luôn các child
REMOVEremove: xóa parent thì xóa các child
REFRESH, DETACHload lại parent từ database, hoặc detach nó
ALLtấ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:

src/main/java/com/example/demo/order/OrderService.java
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();
    }
}
src/main/java/com/example/demo/order/OrderItem.java
package com.example.demo.order;
 
public record OrderItem(Long productId, int quantity) {
}
src/main/java/com/example/demo/product/ProductService.java
    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:

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

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

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

Java
Order order = orderRepository.findById(1L).orElseThrow();
OrderLine first = order.getLines().get(0);
order.removeLine(first);
entityManager.flush();
Text
update order_lines set order_id=?,product_id=?,quantity=?,unit_price=? where id=?
Text
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:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) 
    private List<OrderLine> lines = new ArrayList<>();
Text
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:

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

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

Text
categories: [Keyboards, Mice, Accessories, Webcams]
tags: [wireless, bestseller, usb-c, rgb, mechanical, streaming]

và sau khi xóa:

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

Text
delete from product_tags where product_id=?
delete from tags where id=?
Text
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:

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

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:

Annotationfetch mặc địnhNó đã làm gì trong catalogue này
@ManyToOneEAGERfindById join categories; findAll() thêm một SELECT cho mỗi category khác nhau
@OneToOneEAGERcustomerRepository.findAll() thêm một SELECT cho mỗi customer để lấy profile
@OneToManyLAZYorder.getLines() chạy SELECT riêng ở lần dùng đầu tiên
@ManyToManyLAZYproduct.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:

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

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

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:

src/main/java/com/example/demo/order/PlaceOrderRequest.java
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) {
    }
}
src/main/java/com/example/demo/order/OrderResponse.java
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());
    }
}
src/main/java/com/example/demo/order/OrderController.java
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:

Bash
curl -s -i http://localhost:8128/api/orders/1
Text
HTTP/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=debuglogging.level.org.springframework.web.servlet.DispatcherServlet=debug, lọc còn các dòng liên quan và bỏ timestamp:

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

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

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

src/main/java/com/example/demo/order/OrderRepository.java
    @EntityGraph(attributePaths = "lines")
    Optional<Order> findWithLinesById(Long id);
src/main/java/com/example/demo/order/OrderService.java
    @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 findBy 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:

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

Bốn bước theo thời gian: findAll() load mười order với mọi collection lines chưa được load, tốn 1 SELECT; map từng order trong persistence context load lines từng order một, tổng cộng 11 SELECT; truy cập lines sau khi context đã đóng ném LazyInitializationException và trả HTTP 500; query có left join fetch o.lines load tất cả chỉ bằng 1 SELECT

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 để ý:

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

src/main/java/com/example/demo/order/OrderRepository.java
    @Query("select o from Order o left join fetch o.lines order by o.id")
    List<Order> findAllWithLines();
src/main/java/com/example/demo/order/OrderService.java
    @Transactional(readOnly = true)
    public List<Order> findAll() {
        return orders.findAll(); 
        return orders.findAllWithLines(); 
    }

Với open-in-view=false, cùng request đó:

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

Mộ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:

src/main/java/com/example/demo/order/OrderRepository.java
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 đó:

Text
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

Vẫ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:

Java
@GetMapping("/lab/orders/{id}/entity")
Order entity(@PathVariable Long id) {
    return service.findById(id);
}
Bash
curl -s -i http://localhost:8128/lab/orders/1/entity

Header và phần đầu của body:

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

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

Text
.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ước idlines. Trong body không có chỗ nào xuất hiện "lines".
  • Vòng lặp là Customer.profileCustomerProfile.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ệ.
  • hibernateLazyInitializer là của Hibernate. Order.customer là một lazy proxy, và một getter của class proxy đó bị serialize thành object rỗng. Một JsonMapper.builder().build() thuần trong Jackson 3.1.5 báo SerializationFeature.FAIL_ON_EMPTY_BEANS đang tắt.

Với open-in-view=false, cùng endpoint đó trả về 500:

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

Bash
curl -s -i http://localhost:8128/api/orders/1
Text
HTTP/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:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":2}]}' http://localhost:8128/api/customers/1/orders
Text
HTTP/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

AnnotationFetch mặc địnhOwning sideSchema sinh raThiết lập cho catalogue này
@ManyToOneEAGERLuôn là phía nàyMột cột foreign key trong bảng của chính nó, products.category_idfetch = LAZY, optional = false, @JoinColumn(nullable = false), không cascade
@OneToManyLAZY@ManyToOne của child, qua mappedByKhô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 = ALLorphanRemoval = true cho line thuộc về order, helper addLineremoveLine
@OneToOneEAGERPhía có @JoinColumn; phía kia dùng mappedByMột cột foreign key kèm UNIQUE constraint, customer_profiles.customer_idfetch = LAZY, optional = false, nullable = false; phía mappedBy vẫn tốn một SELECT
@ManyToManyLAZYPhía có @JoinTable; phía kia dùng mappedByMột join table với hai foreign key, product_tags, có primary key trên cả hai cột khi dùng SetSet, @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 @OneToManymappedBy, 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.profileCustomerProfile.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à LAZYoptional = 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 PageableSort, và trả kết quả phân trang qua API.

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] 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] Auditing với Spring Data JPA: @CreatedDate, @LastModifiedDate và @CreatedBy

Auditing với Spring Data JPA trên Spring Boot 4.1.1 và PostgreSQL: @EnableJpaAuditing, AuditingEntityListener và một base class @MappedSuperclass, Flyway migration thêm column audit NOT NULL vào table đã có dữ liệu, giá trị null âm thầm khi thiếu annotation hoặc listener, Instant so với LocalDateTime và OffsetDateTime cùng giá trị timestamptz thực sự lưu, @LastModifiedDate đổi khi nào và modifyOnCreate thay đổi gì, save() một detached entity ghi null vào created_at và @Column(updatable = false), @CreatedBy lấy từ header X-User qua AuditorAware, DateTimeProvider dựa trên Clock, bulk update và native update bỏ qua auditing, và so sánh với @CreationTimestamp, @UpdateTimestamp của Hibernate.

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.