@Column, @Enumerated và @Embeddable — tinh chỉnh cột, ánh xạ enum, value object inline
Ba annotation bổ trợ quan trọng nhất khi mapping entity: @Column customization, naming strategy camelCase→snake_case, @Enumerated luôn dùng STRING (ORDINAL corrupt khi reorder), và @Embeddable để nhúng value object Money/Address vào cùng table mà không cần JOIN.
TL;DR: Sau khi có @Entity và @Id (bài trước), bước tiếp theo là tinh chỉnh cách từng cột được tạo ra. @Column cho phép đặt tên cột, ràng buộc nullable/unique, và độ dài varchar. Naming strategy mặc định của Spring Boot (SpringPhysicalNamingStrategy) tự đổi camelCase sang snake_case — phần lớn trường hợp không cần @Column(name=...) thủ công. @Enumerated phải luôn dùng EnumType.STRING: nếu dùng ORDINAL, chèn thêm một constant vào giữa enum sẽ làm lệch toàn bộ số thứ tự đã lưu trong DB — data corruption thầm lặng không có lỗi runtime. @Embeddable cho phép nhóm nhiều cột liên quan (như amount + currency của Money) thành một value object trong Java, nhưng vẫn lưu inline trong cùng table chủ — không có JOIN, không có table phụ.
Bài trước — @Entity & @Id đã xác lập Java class nào là entity và cách sinh primary key. Bài này tiến thêm một bước: các cột còn lại trong table được kiểm soát ra sao, và cách gom nhóm cột thành value object mà không cần bảng riêng. Bài sau — Lifecycle callbacks & equals/hashCode sẽ đào sâu vòng đời entity và hợp đồng equals/hashCode.
1. @Column — tinh chỉnh cột
@Column không bắt buộc — Hibernate tự suy ra tên cột từ tên field và dùng kiểu mặc định. Annotation này chỉ cần khi muốn override mặc định:
@Column(
name = "project_name", // ten cot (default: naming strategy tu field)
nullable = false, // NOT NULL constraint
unique = true, // UNIQUE constraint
length = 100, // VARCHAR(100) — chi anh huong String
insertable = true, // co trong INSERT
updatable = false // khong trong UPDATE (vd: created_at)
)
private String name;
Bảng các pattern phổ biến nhất trong thực tế:
| Field | Annotation |
|---|---|
| Bắt buộc nhập | @Column(nullable = false) |
| Unique constraint | @Column(unique = true) |
| String với giới hạn | @Column(length = 100) (mặc định 255) |
| Text dài (blog, description) | @Column(columnDefinition = "TEXT") |
| Timestamp tạo (không đổi) | @Column(updatable = false) |
| Số thập phân (tiền tệ) | @Column(precision = 19, scale = 4) — BigDecimal |
| Tên cột legacy | @Column(name = "usr_nm") |
Lưu ý quan trọng: thuộc tính length chỉ ảnh hưởng kiểu VARCHAR — các kiểu số (Integer, Long, BigDecimal) bỏ qua giá trị này hoàn toàn.
Đừng viết @Column cho mọi field. Chỉ annotate khi cần override: đặt NOT NULL, giới hạn độ dài, hoặc tên cột đặc biệt. Trust naming strategy cho phần còn lại.
2. Naming strategy — camelCase tự động thành snake_case
Spring Boot mặc định cấu hình SpringPhysicalNamingStrategy — đây là cầu nối giữa tên field Java (camelCase) và tên cột Postgres (snake_case). Bạn không cần cấu hình thủ công; đây là hành vi mặc định kể từ Spring Boot 2.x:
# application.yml — da la default, khong can viet ra
spring:
jpa:
hibernate:
naming:
physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
Quy tắc chuyển đổi:
| Java field | Cột DB | Ghi chú |
|---|---|---|
firstName | first_name | camelCase → snake_case |
createdAt | created_at | Instant, LocalDateTime |
xmlData | xml_data | viết thường + gạch dưới |
URLPath | url_path | viết hoa liên tiếp → lowercase |
isActive | is_active | prefix boolean giữ nguyên |
@Entity
public class Project {
@Id Long id; // → id
String name; // → name
String description; // → description
Instant createdAt; // → created_at (auto — khong can @Column)
String projectCode; // → project_code (auto)
}
flowchart LR
subgraph J["Java fields (camelCase)"]
direction TB
F1["Long id"]
F2["String name"]
F3["Instant createdAt"]
F4["String projectCode"]
end
subgraph C["DB columns (snake_case)"]
direction TB
C1["id BIGINT PK"]
C2["name VARCHAR(255)"]
C3["created_at TIMESTAMP"]
C4["project_code VARCHAR(255)"]
end
F1 -->|"SpringPhysicalNamingStrategy"| C1
F2 --> C2
F3 --> C3
F4 --> C4Override chỉ khi cần:
@Column(name = "prj_cd") // ten cu trong DB legacy
private String projectCode;
3. @Enumerated — luôn dùng STRING
Hibernate hỗ trợ hai mode để lưu enum vào DB:
| Mode | DB lưu | Đọc ra |
|---|---|---|
EnumType.STRING | 'ACTIVE' (varchar) | Tên constant — an toàn khi refactor |
EnumType.ORDINAL | 1 (integer) | Vị trí trong danh sách — nguy hiểm |
Luôn dùng EnumType.STRING:
public enum ProjectStatus {
PLANNING, ACTIVE, DONE, ARCHIVED
}
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProjectStatus status;
Schema được tạo ra:
status VARCHAR(20) NOT NULL
-- DB luu: 'PLANNING', 'ACTIVE', 'DONE', 'ARCHIVED'
3.1 Cơ chế bên dưới — vì sao ORDINAL corrupt dữ liệu
ORDINAL lưu vị trí số của constant trong enum (bắt đầu từ 0). Khi bạn thêm hoặc chèn một constant mới vào giữa danh sách, toàn bộ vị trí bị lệch — nhưng các row cũ trong DB vẫn giữ nguyên số cũ, dẫn đến mỗi row đọc ra một constant sai:
// v1 — enum ban dau
public enum Status { PLANNING, ACTIVE, DONE }
// DB luu: PLANNING=0, ACTIVE=1, DONE=2
// v2 — them INTERNAL_REVIEW vao giua
public enum Status { PLANNING, INTERNAL_REVIEW, ACTIVE, DONE }
// DB luu moi: PLANNING=0, INTERNAL_REVIEW=1, ACTIVE=2, DONE=3
// Row cu co status=1 bay gio doc la INTERNAL_REVIEW -- sai hoan toan
flowchart TB
ROW["Row cu trong DB: status = 1"]
subgraph V1["enum v1 -- ORDINAL dung"]
direction TB
E1A["PLANNING = 0"]
E1B["ACTIVE = 1"]
E1C["DONE = 2"]
end
subgraph V2["enum v2 -- chen INTERNAL_REVIEW vao giua"]
direction TB
E2A["PLANNING = 0"]
E2B["INTERNAL_REVIEW = 1"]
E2C["ACTIVE = 2"]
E2D["DONE = 3"]
end
ROW -->|"v1: doc dung -- ACTIVE"| E1B
ROW -->|"v2: doc sai -- INTERNAL_REVIEW CORRUPT"| E2BĐiều nguy hiểm nhất của lỗi này là không có exception — code chạy bình thường, chỉ dữ liệu bị sai nghĩa thầm lặng. Một unit record "đang ACTIVE" đột nhiên trở thành "INTERNAL_REVIEW" sau khi deploy — bug xuất hiện trong production, khó trace về nguyên nhân.
EnumType.STRING lưu 'ACTIVE' (chuỗi), nên reorder hay thêm constant vào bất kỳ vị trí nào đều không ảnh hưởng các row đã có. Chi phí duy nhất là cột varchar hơi lớn hơn integer — hoàn toàn chấp nhận được.
Không bao giờ dùng EnumType.ORDINAL. Cost của VARCHAR so với INTEGER là không đáng kể. Cost của data corruption không thể đo được.
3.2 Custom mapping với @Converter
Khi cần lưu enum với giá trị tùy chỉnh (ví dụ legacy code 'P' thay vì 'PLANNING'), dùng AttributeConverter:
public enum ProjectStatus {
PLANNING("P"), ACTIVE("A"), DONE("D"), ARCHIVED("X");
private final String code;
ProjectStatus(String code) { this.code = code; }
public String getCode() { return code; }
public static ProjectStatus from(String code) {
for (ProjectStatus s : values()) {
if (s.code.equals(code)) return s;
}
throw new IllegalArgumentException("Unknown status code: " + code);
}
}
@Converter
public class ProjectStatusConverter
implements AttributeConverter<ProjectStatus, String> {
@Override
public String convertToDatabaseColumn(ProjectStatus s) {
return s == null ? null : s.getCode();
}
@Override
public ProjectStatus convertToEntityAttribute(String code) {
return code == null ? null : ProjectStatus.from(code);
}
}
// Su dung:
@Convert(converter = ProjectStatusConverter.class)
private ProjectStatus status;
@Converter cho phép kiểm soát hoàn toàn giá trị lưu — hữu ích khi integrate với schema legacy hoặc khi cần value nhỏ gọn.
4. @Embeddable — value object không cần JOIN
Một số nhóm cột thuộc về nhau về mặt ngữ nghĩa nhưng không phải entity độc lập — chúng không có identity riêng, vòng đời hoàn toàn phụ thuộc vào entity chủ. Ví dụ điển hình: Money (amount + currency), Address (street, city, zip), DateRange (startDate, endDate).
JPA cung cấp @Embeddable để mô hình hóa những value object này trong Java mà vẫn lưu inline trong table chủ:
@Embeddable
public class Money {
@Column(name = "amount_value", precision = 19, scale = 4)
private BigDecimal amount;
@Column(name = "amount_currency", length = 3)
private String currency;
protected Money() {} // JPA require no-arg
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public BigDecimal getAmount() { return amount; }
public String getCurrency() { return currency; }
}
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Money total; // → 2 cot: amount_value, amount_currency
// getters...
}
Schema được tạo ra chỉ có một table — hai cột của Money nằm thẳng trong orders:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
amount_value NUMERIC(19, 4),
amount_currency VARCHAR(3)
);
4.1 Cơ chế bên dưới — tại sao không cần JOIN
Khi Hibernate xử lý @Embeddable, nó mở phẳng (flatten) các field của value object vào DDL của entity chủ. Không có foreign key, không có table phụ. Khi đọc một Order, Hibernate chạy một SELECT duy nhất và dựng lại object Money từ các cột inline:
flowchart LR
subgraph E["@Entity Order (Java)"]
direction TB
O1["Long id"]
O2["@Embedded Money total"]
end
subgraph M["@Embeddable Money (Java)"]
direction TB
M1["BigDecimal amount"]
M2["String currency"]
end
subgraph T["Table orders (DB) -- 1 table, khong JOIN"]
direction TB
C1["id BIGINT PK"]
C2["amount_value NUMERIC"]
C3["amount_currency VARCHAR"]
end
O1 --> C1
O2 --> M
M1 -->|"cot inline"| C2
M2 -->|"cot inline"| C3Đây là điểm khác biệt cốt lõi so với quan hệ entity-entity (@OneToOne, @ManyToOne): quan hệ đó dùng foreign key + JOIN khi load. @Embeddable không có JOIN nào — cột thuộc về cùng table, cùng row.
Khi nào dùng @Embeddable vs entity riêng?
| Tiêu chí | @Embeddable | Entity riêng |
|---|---|---|
| Có identity (ID) riêng không | Không | Có |
| Vòng đời phụ thuộc entity chủ | Có | Không (CRUD độc lập) |
| Cần reference từ nhiều entity | Không (copy value) | Có (FK reference) |
| Cần query theo giá trị | Được (WHERE amount_value > ?) | Được (JOIN) |
| Ví dụ | Money, Address, DateRange | User, Product, Category |
4.2 Reuse value object với @AttributeOverrides
Khi một entity cần nhúng cùng một @Embeddable nhiều lần, tên cột sẽ xung đột. Giải quyết bằng @AttributeOverrides:
@Entity
public class Order {
@Id Long id;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "subtotal_value")),
@AttributeOverride(name = "currency", column = @Column(name = "subtotal_currency"))
})
private Money subtotal;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "tax_value")),
@AttributeOverride(name = "currency", column = @Column(name = "tax_currency"))
})
private Money tax;
}
Schema kết quả vẫn là một table duy nhất với 4 cột tiền tệ:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
subtotal_value NUMERIC(19, 4),
subtotal_currency VARCHAR(3),
tax_value NUMERIC(19, 4),
tax_currency VARCHAR(3)
);
5. Pitfall tổng hợp
❌ Nhầm 1 — EnumType.ORDINAL mặc định (không ghi gì):
@Enumerated // SAI — default la ORDINAL
private ProjectStatus status;
Hibernate mặc định dùng ORDINAL khi chỉ viết @Enumerated không có tham số. Luôn viết đầy đủ:
@Enumerated(EnumType.STRING) // DUNG
@Column(nullable = false, length = 20)
private ProjectStatus status;
❌ Nhầm 2 — Quên @Enumerated hoàn toàn:
// khong @Enumerated
private ProjectStatus status;
Hibernate 6+ xử lý enum không có annotation theo kiểu ORDINAL mặc định (hành vi phụ thuộc phiên bản). Kết quả: dữ liệu lưu là số nguyên, không có lỗi compile hay runtime — chỉ phát hiện khi check DB.
❌ Nhầm 3 — Dùng @Embeddable cho object có identity riêng:
@Embeddable // SAI — Category co the ton tai doc lap
public class Category {
private Long id;
private String name;
}
Nếu object có ID riêng, cần CRUD độc lập, hoặc được nhiều entity reference — đó là entity, không phải value object. Dùng @Entity + quan hệ @ManyToOne.
❌ Nhầm 4 — Để cột @Embeddable nullable khi nhúng vào required field:
@Embedded
private Money total; // total la bat buoc, nhung
// amount_value + amount_currency van la nullable trong DB
@Column(nullable = false) phải khai báo trong class @Embeddable, hoặc dùng @AttributeOverride kết hợp column = @Column(nullable = false).
Liên hệ các bài khác
- Bài 03 — @Entity & @Id: nền tảng để áp dụng các annotation trong bài này. Các chiến lược
@GeneratedValue(IDENTITY, SEQUENCE, UUID) quyết định cộtid— bài này chỉ bổ sung các cột còn lại. - Bài 05 — Lifecycle callbacks & equals/hashCode: sau khi mapping đúng, entity cần hợp đồng equals/hashCode để hoạt động đúng trong Set/Map và persistence context.
@PrePersistcũng liên quan cách set default value cho enum field.
Tóm tắt
@Columntinh chỉnh tên cột, nullable, unique, độ dài — chỉ cần khi override mặc định.SpringPhysicalNamingStrategy(Spring Boot default) tự chuyểncamelCase→snake_case— không cần@Column(name=...)cho phần lớn trường hợp.@Enumerated(EnumType.STRING)là quy tắc không ngoại lệ.ORDINALkhông có lỗi runtime nhưng corrupt dữ liệu khi reorder enum — lỗi thầm lặng nguy hiểm nhất trong JPA mapping.@Embeddablegom nhiều cột liên quan thành một value object trong Java, lưu inline trong table chủ — không JOIN, không table phụ. Reuse bằng@AttributeOverrides.
Tự kiểm tra
Q1Tại sao @Enumerated(EnumType.ORDINAL) có thể gây corrupt dữ liệu mà không có exception nào? Cho ví dụ cụ thể với enum Status { PLANNING, ACTIVE, DONE }.▸
@Enumerated(EnumType.ORDINAL) có thể gây corrupt dữ liệu mà không có exception nào? Cho ví dụ cụ thể với enum Status { PLANNING, ACTIVE, DONE }.ORDINAL lưu vị trí số của constant trong enum (bắt đầu từ 0). Khi bạn thêm một constant mới vào giữa danh sách, tất cả constant sau nó bị tăng số thứ tự lên 1. Nhưng các row cũ trong DB vẫn giữ nguyên số cũ — không có migration, không có lỗi — nên mỗi row đọc ra một constant sai.
Ví dụ cụ thể:
Trước: Status { PLANNING=0, ACTIVE=1, DONE=2 }. Một row được lưu với status=1 (tức ACTIVE).
Sau khi thêm INTERNAL_REVIEW vào giữa: Status { PLANNING=0, INTERNAL_REVIEW=1, ACTIVE=2, DONE=3 }. Row cũ vẫn có status=1, nhưng bây giờ 1 là INTERNAL_REVIEW — đọc sai hoàn toàn.
Điều nguy hiểm nhất là không có exception: Hibernate đọc số nguyên, tra bảng vị trí, trả về constant — mọi thứ chạy bình thường về mặt kỹ thuật. Lỗi chỉ lộ ra khi logic nghiệp vụ hoạt động sai, khó trace về nguyên nhân.
EnumType.STRING lưu 'ACTIVE' — reorder hay thêm constant không ảnh hưởng row cũ vì chuỗi tra theo tên, không theo vị trí.
Q2Field private Instant createdAt trong entity có cần @Column(name = "created_at") không? Naming strategy xử lý thế nào?▸
private Instant createdAt trong entity có cần @Column(name = "created_at") không? Naming strategy xử lý thế nào?Không cần. SpringPhysicalNamingStrategy (mặc định Spring Boot) tự chuyển createdAt → created_at theo quy tắc camelCase sang snake_case.
Strategy phát hiện ranh giới chữ hoa — tại vị trí chuyển từ d sang A trong createdAt — và chèn dấu gạch dưới, đồng thời viết thường toàn bộ. Kết quả: created_at đúng convention Postgres.
Chỉ cần @Column(name = ...) khi tên cột thực tế khác với quy ước naming strategy: tên cột legacy (ví dụ crt_ts), tên có prefix đặc biệt, hoặc khi cần tránh conflict với SQL reserved keyword.
Quy tắc thực tế: trust naming strategy cho 90% field. Chỉ override khi có lý do cụ thể — viết @Column thừa cho mọi field là noise không cần thiết.
Q3So sánh @Embeddable Money với entity PaymentMethod có @Id. Khi nào chọn cái nào? Sự khác biệt ở DB schema là gì?▸
@Embeddable Money với entity PaymentMethod có @Id. Khi nào chọn cái nào? Sự khác biệt ở DB schema là gì?Tiêu chí quyết định: object có identity (ID) riêng không?
@Embeddable Money là value object — không có ID, vòng đời hoàn toàn phụ thuộc entity chủ (Order). Hibernate lưu các cột của Money thẳng vào table orders. Không có table phụ, không có JOIN khi đọc Order. Hai Money(100, "USD") và Money(100, "USD") được coi là bằng nhau nếu tất cả field bằng nhau — equality by value.
PaymentMethod là entity — có @Id, có thể tồn tại độc lập, có thể được nhiều Order reference qua foreign key. DB schema có table payment_methods riêng. Khi đọc Order kèm PaymentMethod, Hibernate cần JOIN.
Quy tắc: nếu object chỉ là "nhóm cột mang ý nghĩa nghiệp vụ chung" (tiền tệ, địa chỉ, khoảng thời gian) — dùng @Embeddable. Nếu object có lifecycle độc lập, cần CRUD riêng, hoặc được share — dùng entity.
Q4Entity Order muốn nhúng Money hai lần: subtotal và tax. Nếu không dùng @AttributeOverrides thì điều gì xảy ra? Sửa thế nào?▸
Order muốn nhúng Money hai lần: subtotal và tax. Nếu không dùng @AttributeOverrides thì điều gì xảy ra? Sửa thế nào?Nếu nhúng cùng một @Embeddable hai lần mà không override tên cột, Hibernate sẽ cố tạo hai cột cùng tên (ví dụ amount_value và amount_value) — dẫn đến lỗi khi Hibernate validate schema hoặc khi migration chạy: Column 'amount_value' already exists (tùy dialect).
Sửa bằng @AttributeOverrides để đặt tên cột khác nhau cho mỗi lần nhúng:
Lần nhúng subtotal: override amount → subtotal_value, currency → subtotal_currency.
Lần nhúng tax: override amount → tax_value, currency → tax_currency.
Kết quả là bốn cột phân biệt trong cùng một table — không JOIN, không table phụ. Cú pháp: @AttributeOverride(name = "amount", column = @Column(name = "subtotal_value")) đặt bên cạnh @Embedded.
Q5Bạn nhận được entity sau từ teammate. Tìm ít nhất 3 vấn đề liên quan đến nội dung bài này và đề xuất sửa.@Entity
public class Invoice {
@Id @GeneratedValue
private Long id;
@Column(name = "invoice_amount")
private BigDecimal amount;
@Column(name = "invoice_currency")
private String currency;
@Enumerated
@Column(nullable = false)
private InvoiceStatus status;
@Column(name = "created_at")
private Instant createdAt;
}
▸
@Entity
public class Invoice {
@Id @GeneratedValue
private Long id;
@Column(name = "invoice_amount")
private BigDecimal amount;
@Column(name = "invoice_currency")
private String currency;
@Enumerated
@Column(nullable = false)
private InvoiceStatus status;
@Column(name = "created_at")
private Instant createdAt;
}Vấn đề 1 — @Enumerated không có tham số: mặc định là ORDINAL. Nếu team sau này thêm constant vào giữa InvoiceStatus, dữ liệu cũ bị đọc sai mà không có exception. Sửa: @Enumerated(EnumType.STRING) và thêm length = 20 vào @Column.
Vấn đề 2 — amount và currency nên là @Embeddable Money: hai field này thuộc về nhau về mặt nghiệp vụ (số tiền + đơn vị tiền tệ). Nếu cần thêm tax hoặc subtotal sau này, sẽ lại phải thêm cặp field tương tự. Gom thành @Embeddable Money giúp reuse và giữ ngữ nghĩa rõ ràng trong code Java.
Vấn đề 3 — @Column(name = "created_at") thừa: SpringPhysicalNamingStrategy mặc định đã chuyển createdAt → created_at. Annotation này không sai nhưng là noise — cộng thêm updatable = false mới có ý nghĩa thực sự (timestamp tạo không được cập nhật).
Sửa đầy đủ:
Tách Money thành @Embeddable với amount_value (precision=19, scale=4) và amount_currency (length=3). Dùng @Embedded private Money total trong Invoice. Đổi @Enumerated(EnumType.STRING) với @Column(nullable=false, length=20). Đổi createdAt sang @Column(updatable=false) và bỏ name thủ công.
Bài tiếp theo: Lifecycle callbacks & equals/hashCode
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên