Part 04 · PostgreSQL & Redis · 4.1.05A

Database relations và JPA mapping

Cardinality luôn được đọc từ một phía cụ thể. Cùng quan hệ Department–Employee là one-to-many khi nhìn từ Department và many-to-one khi nhìn từ Employee.


1. Quy tắc xác định cardinality

  1. Chọn một record ở bảng A.
  2. Hỏi record đó liên kết tối đa với bao nhiêu record ở B: một hay nhiều?
  3. Đổi chiều và hỏi lại từ B sang A.
  4. Với quan hệ 1–N thông thường, đặt foreign key ở phía “nhiều”.
Câu thần chú: “Nhiều Employee thuộc một Department” nghĩa là Employee có quan hệ many-to-one và bảng employee giữ department_id. Đọc ngược lại, “Một Department có nhiều Employee” là one-to-many.

2. Bảng tổng hợp các kiểu quan hệ

Quan hệĐọc thành câuThiết kế relationalJPA mapping thường gặp
One-to-one (1–1)Một User có tối đa một Profile.Foreign key ở một bảng kèm UNIQUE, hoặc shared primary key.@OneToOne; phía có @JoinColumn là owning side.
One-to-many (1–N)Một Department có nhiều Employee.department_id nằm trong bảng employee.@OneToMany(mappedBy="department") thường là inverse side.
Many-to-one (N–1)Nhiều Employee thuộc một Department.Cũng chính foreign key employee.department_id.@ManyToOne@JoinColumn ở Employee, thường là owning side.
Many-to-many (N–N)Một Student học nhiều Course; một Course có nhiều Student.Bảng nối enrollment(student_id, course_id).Có thể dùng @ManyToMany, nhưng nên map bảng nối thành entity khi quan hệ có dữ liệu nghiệp vụ.

Điểm cốt lõi: one-to-many và many-to-one không phải hai quan hệ database khác nhau; chúng là hai hướng nhìn của cùng một foreign key.

3. SQL mẫu: Department và Employee

CREATE TABLE department (
    id BIGINT PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE employee (
    id BIGINT PRIMARY KEY,
    name TEXT NOT NULL,
    department_id BIGINT NOT NULL,
    FOREIGN KEY (department_id) REFERENCES department(id)
);

Mỗi row employee chỉ chứa một department_id, nhưng nhiều employee có thể lặp cùng giá trị đó. Vì vậy Employee → Department là N–1, còn Department → Employee là 1–N. Index foreign key không được PostgreSQL tự động tạo; hãy thêm index khi access pattern, join hoặc parent delete/update cần nó.

4. Mapping JPA hai chiều và owning side

@Entity
class Employee {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "department_id", nullable = false)
    private Department department;
}

@Entity
class Department {
    @Id
    private Long id;

    @OneToMany(mappedBy = "department")
    private List<Employee> employees = new ArrayList<>();
}
public void addEmployee(Employee employee) {
    employees.add(employee);
    employee.setDepartment(this);
}
Giữ hai chiều đồng bộ: helper method nên cập nhật cả collection và owning side. Nếu không cần đi từ parent sang children trong domain model, mapping một chiều ở phía many-to-one thường đơn giản và ít lỗi hơn.

5. One-to-one cần UNIQUE

CREATE TABLE user_profile (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL UNIQUE,
    FOREIGN KEY (user_id) REFERENCES app_user(id)
);

Foreign key một mình chỉ đảm bảo mỗi profile trỏ tới một user. Nếu không có UNIQUE(user_id), nhiều profile vẫn có thể trỏ cùng user và quan hệ thực tế trở thành many-to-one. Shared primary key là lựa chọn khác khi child phụ thuộc hoàn toàn vào identity và lifecycle của parent.

6. Many-to-many và entity bảng nối

CREATE TABLE enrollment (
    student_id BIGINT NOT NULL REFERENCES student(id),
    course_id BIGINT NOT NULL REFERENCES course(id),
    enrolled_at TIMESTAMPTZ NOT NULL,
    status TEXT NOT NULL,
    PRIMARY KEY (student_id, course_id)
);

Khi bảng nối có status, thời gian, role, quantity hoặc audit fields, nó đã là một domain concept. Map thành Enrollment với hai quan hệ many-to-one giúp model lifecycle, constraints và query rõ hơn @ManyToMany trực tiếp. Nếu một sinh viên có thể đăng ký lại cùng khóa học nhiều lần, composite key trên hai foreign key không còn đủ; cần identity hoặc key bao gồm attempt/version phù hợp invariant.

7. Optionality khác cardinality

Cardinality cần đọc cả min và max: 0..1, 1, 0..* hoặc 1..*. Employee có thể chưa thuộc department là 0..1 ở chiều Employee → Department, nên foreign key nullable. Nếu bắt buộc, dùng cả NOT NULLoptional = false. Annotation JPA giúp ORM hiểu model nhưng không thay database constraint cho các writer khác.

8. Các bẫy JPA thường gặp

BẫyCách suy nghĩ đúng
Fetch mặc địnhTheo Jakarta Persistence, to-one mặc định EAGER, còn to-many mặc định LAZY. LAZY chỉ là hint cho to-one theo spec, nên phải đo SQL thực tế của provider và thiết kế query theo use case.
N+1 queryLoad một danh sách rồi chạm association từng row có thể tạo N query phụ. Dùng fetch join, entity graph, projection hoặc batch phù hợp; không đổi mọi association thành EAGER.
CascadeCascade JPA truyền operation trong persistence context; nó không đồng nghĩa với ON DELETE CASCADE của database.
orphanRemovalChỉ dùng khi child thực sự thuộc lifecycle độc quyền của parent; remove khỏi collection có thể dẫn tới DELETE.
JSON recursionSerialize association hai chiều trực tiếp dễ lặp vô hạn hoặc vô tình load cả graph. Dùng DTO tại API boundary.
equals/hashCodeKhông đưa mutable collection hoặc lazy association vào equality. Với generated ID, phải cân nhắc trạng thái transient và tính ổn định khi entity nằm trong hash collection.

9. Sơ đồ quyết định nhanh

A có nhiều B, mỗi B thuộc một A
    => foreign key a_id nằm ở B
    => B: @ManyToOne + @JoinColumn
    => A: @OneToMany(mappedBy = "a") nếu cần đi ngược

A có nhiều B và B cũng có nhiều A
    => cần bảng nối A_B
    => có dữ liệu nghiệp vụ trên quan hệ? Map A_B thành entity

A chỉ có tối đa một B
    => foreign key + UNIQUE, hoặc shared primary key

10. Checklist tự kiểm tra

Nguồn tham khảo