Spring REST API & Data JPA/@Column, @Enumerated và @Embeddable — tinh chỉnh cột, ánh xạ enum, value object inline
26/46
Bài 26 / 46~12 phútJPA FundamentalsMiễn phí lượt xem

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

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

Quy tắc thực tế

Đừ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 fieldCột DBGhi chú
firstNamefirst_namecamelCase → snake_case
createdAtcreated_atInstant, LocalDateTime
xmlDataxml_dataviết thường + gạch dưới
URLPathurl_pathviết hoa liên tiếp → lowercase
isActiveis_activeprefix 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 --> C4

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

ModeDB lưuĐọc ra
EnumType.STRING'ACTIVE' (varchar)Tên constant — an toàn khi refactor
EnumType.ORDINAL1 (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.

Quy tắc không ngoại lệ

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í@EmbeddableEntity riêng
Có identity (ID) riêng khôngKhông
Vòng đời phụ thuộc entity chủKhông (CRUD độc lập)
Cần reference từ nhiều entityKhông (copy value)Có (FK reference)
Cần query theo giá trịĐược (WHERE amount_value > ?)Được (JOIN)
Ví dụMoney, Address, DateRangeUser, 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ột id — 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. @PrePersist cũng liên quan cách set default value cho enum field.

Tóm tắt

  • @Column tinh chỉnh tên cột, nullable, unique, độ dài — chỉ cần khi override mặc định.
  • SpringPhysicalNamingStrategy (Spring Boot default) tự chuyển camelCasesnake_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ệ. ORDINAL khô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.
  • @Embeddable gom 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

Tự kiểm tra
Q1
Tạ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 }.

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

Q2
Field 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 createdAtcreated_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.

Q3
So sánh @Embeddable Money với entity PaymentMethod@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 Moneyvalue 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")Money(100, "USD") được coi là bằng nhau nếu tất cả field bằng nhau — equality by value.

PaymentMethodentity — 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.

Q4
Entity Order muốn nhúng Money hai lần: subtotaltax. 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_valueamount_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 amountsubtotal_value, currencysubtotal_currency.

Lần nhúng tax: override amounttax_value, currencytax_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.

Q5
Bạ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;
}

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 — amountcurrency 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 createdAtcreated_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

Đặt 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