Entity Relationships: owning side, mappedBy, cascade, orphanRemoval

4 câu hỏi~25 phútTừ khóa: @OneToMany, @ManyToOne, @JoinColumn, owning side, inverse side, mappedBy, bidirectional helper, CascadeType, orphanRemoval, @ManyToMany

Tiếp tục giải thích về quan hệ Entity, Cascade và Orphan Removal đi.

1. Bài toán: Order và OrderItem

Mô hình shop-app:

users
  | 1 - N
  v
orders
  | 1 - N
  v
order_items
  | N - 1
  v
products
CREATE TABLE orders (
    id      BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    status  VARCHAR(20) NOT NULL
);

CREATE TABLE order_items (
    id         BIGSERIAL PRIMARY KEY,
    order_id   BIGINT NOT NULL REFERENCES orders(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    quantity   INTEGER NOT NULL
);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

Khóa ngoại order_id nằm ở bảng order_items. Muốn: tạo Order kèm 3 item bằng một lần save(); bỏ item khỏi Order thì item bị xóa.

2. Vấn đề

3. Giải pháp: owning side + mappedBy + cascade

4. Code từng bước

Bước 1: Dependency. Như bài 4.3.1.

Bước 2: Config

spring:
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
logging:
  level:
    org.hibernate.SQL: debug

Bước 3: Code Java. Entity Order (inverse side + aggregate root):

@Entity
@Table(name = "orders")   // "order" là từ khóa SQL
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id")
    private User user;

    @Enumerated(EnumType.STRING)
    private OrderStatus status = OrderStatus.NEW;

    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    protected Order() {}
    public Order(User user) { this.user = user; }

    // Helper: luôn cập nhật CẢ HAI phía
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);  // buộc đi qua helper
    }
}

Entity OrderItem (owning side):

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id")
    private Product product;      // KHÔNG cascade: Product là dữ liệu dùng chung

    private Integer quantity;

    protected OrderItem() {}
    public OrderItem(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }
    void setOrder(Order order) { this.order = order; }
}

Service:

@Transactional
public Long placeOrder(Long userId, Map<Long, Integer> lines) {
    User user = userRepository.getReferenceById(userId);   // proxy, không SELECT
    Order order = new Order(user);
    lines.forEach((productId, qty) ->
        order.addItem(new OrderItem(productRepository.getReferenceById(productId), qty)));
    return orderRepository.save(order).getId();   // cascade PERSIST → INSERT cả items
}

@Transactional
public void removeFirstItem(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    OrderItem item = order.getItems().get(0);
    order.removeItem(item);       // orphanRemoval → DELETE khi flush
}

Bước 4: Test bằng curl

curl -X POST http://localhost:8080/orders \
  -H "Content-Type: application/json" \
  -d '{"userId":10,"items":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}'
# log:
# insert into orders (status,user_id) values (?,?) returning id
# insert into order_items (order_id,product_id,quantity) values (?,?,?)
# insert into order_items (order_id,product_id,quantity) values (?,?,?)

curl -X DELETE http://localhost:8080/orders/1/items/first
# log: delete from order_items where id=?

5. Cách hoạt động

             Java (2 phía)                          DB (1 cột)
+---------------------------+
| Order                     |
|  items  (mappedBy="order")| -- inverse: Hibernate BỎ QUA khi ghi FK
+---------------------------+
           ^      |
           |      v
+---------------------------+            order_items
| OrderItem                 |            +----------+
|  order  @JoinColumn       | ---------> | order_id |  owning: quyết định FK
+---------------------------+            +----------+

save(order)
  └─ cascade PERSIST → persist(item1), persist(item2)
       └─ FK lấy từ item.order (nhờ addItem đã set)

order.removeItem(item)
  └─ item không còn trong items → orphan → DELETE khi flush

6. So sánh

CascadeÝ nghĩa
PERSISTpersist cha thì persist con
MERGEmerge cha thì merge con
REMOVExóa cha thì xóa con
REFRESH / DETACHrefresh / detach lan xuống con
ALLTất cả ở trên
CascadeType.REMOVEorphanRemoval = true
Kích hoạt khiXóa Entity chaGỡ con khỏi collection (hoặc xóa cha)
Ví dụorderRepository.delete(order) → xóa itemsorder.removeItem(item) → xóa item
Phù hợpCon thuộc sở hữu hoàn toàn của chaCon không thể tồn tại một mình (OrderItem)
Quan hệVí dụ shop-appGhi chú
@ManyToOneOrderItem → ProductMặc định EAGER, nên đổi LAZY
@OneToManyOrder → itemsMặc định LAZY; dùng mappedBy
@OneToOneUser → UserProfilePhía inverse khó lazy; cân nhắc @MapsId
@ManyToManyProduct ↔ CategoryDùng Set; bảng trung gian có thêm cột thì tách Entity riêng

7. Khi nào nên dùng / không nên dùng

Nếu chỉ dùng @OneToMany một chiều, không có ManyToOne ở OrderItem thì sao?

Chạy được, nhưng cần cẩn thận. Có hai kiểu:

// 1. Không @JoinColumn: Hibernate tạo BẢNG NỐI orders_items (order_id, items_id)
@OneToMany
private List<OrderItem> items;

// 2. Có @JoinColumn: dùng cột order_id trong order_items
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderItem> items;

Kiểu 1 thừa một bảng. Kiểu 2 hợp lý hơn nhưng Hibernate có thể INSERT item trước với order_id rồi mới UPDATE (tùy phiên bản và nullable), tức là thêm câu SQL. Cách phổ biến và hiệu quả nhất vẫn là @ManyToOne ở con, còn @OneToMany(mappedBy) chỉ thêm khi thật sự cần điều hướng từ cha.

Dùng Lombok @Data cho Entity có vấn đề gì không?

Có, đây là lỗi rất thường gặp với quan hệ hai chiều:

Dùng @Getter/@Setter có chọn lọc. equals/hashCode dựa trên id (với điều kiện id null thì không bằng nhau, hashCode trả hằng số theo class) hoặc business key tự nhiên.

Phỏng vấn hỏi về relationship, mappedBy, cascade thì trả lời sao?

Q1. Owning side là gì? mappedBy dùng để làm gì?

🇻🇳 Tiếng Việt:

Trong quan hệ hai chiều, owning side là phía giữ khóa ngoại, có @JoinColumn; với 1-N là phía @ManyToOne. mappedBy đặt ở phía inverse, chỉ ra field nào ở phía kia sở hữu quan hệ. Hibernate chỉ dùng owning side để ghi khóa ngoại, nên phải luôn set phía đó, tốt nhất qua helper method cập nhật cả hai phía.

🇬🇧 English:

In a bidirectional relationship, the owning side is the one that holds the foreign key and has @JoinColumn; for one-to-many it is the @ManyToOne side. mappedBy is placed on the inverse side and names the field that owns the relationship. Hibernate only uses the owning side to write the foreign key, so we must always set it, ideally through a helper method that updates both sides.

Q2. CascadeType.REMOVE khác orphanRemoval thế nào?

🇻🇳 Tiếng Việt:

REMOVE lan truyền thao tác xóa từ cha xuống con khi xóa cha. orphanRemoval xóa con khi nó bị gỡ khỏi quan hệ với cha, kể cả khi cha vẫn còn. Không nên dùng REMOVE cho dữ liệu dùng chung như Product hay Category.

🇬🇧 English:

CascadeType.REMOVE cascades entity removal from the parent to its children when the parent is deleted. orphanRemoval deletes a child when it is removed from the parent's relationship, even if the parent still exists. REMOVE should not be used for shared data such as Product or Category.

Q3. Vì sao nên để @ManyToOne là LAZY?

🇻🇳 Tiếng Việt:

@ManyToOne và @OneToOne mặc định EAGER, nên mỗi lần load Entity sẽ kéo theo quan hệ, dễ gây N+1 và load dữ liệu thừa. Em để LAZY hết rồi load khi cần bằng JOIN FETCH, EntityGraph hoặc DTO projection.

🇬🇧 English:

@ManyToOne and @OneToOne are EAGER by default, so every load drags the association along, which causes N+1 queries and over-fetching. I make them LAZY and fetch what each use case needs with JOIN FETCH, an EntityGraph or a DTO projection.

Cách nhớ ngắn gọn


Tự kiểm tra

Trong Order-OrderItem, phía nào là owning side?

OrderItem.order (@ManyToOne + @JoinColumn(name = "order_id")).

Chỉ gọi order.getItems().add(item) mà không set item.order thì sao?

Hibernate bỏ qua phía inverse, order_id = NULL hoặc lỗi NOT NULL.

Gỡ item khỏi order.items có orphanRemoval = true, SQL nào chạy?

DELETE FROM order_items WHERE id = ? khi flush.

Vì sao không đặt cascade trên OrderItem.product?

Product là dữ liệu dùng chung; cascade REMOVE/MERGE có thể xóa hoặc ghi đè Product ngoài ý muốn.

Khi nào thay @ManyToMany bằng Entity trung gian?

Khi bảng nối có thêm cột (created_at, priority, status) hoặc cần truy vấn/quản lý riêng.