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)
}

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

Một row cũ trong DB có status bằng 1, tra vào enum phiên bản một thì ra ACTIVE, tra vào enum phiên bản hai sau khi chèn INTERNAL_REVIEW vào giữa thì cùng con số đó ra INTERNAL_REVIEW; giá trị lưu không đổi mà nghĩa của nó đổi

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

Đâ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.

Hai lối rẽ đặt cạnh nhau ở mức schema: @Embedded Money bị mở phẳng thành các cột amount_value và amount_currency ngay trong bảng orders nên đọc một Order chỉ tốn một câu SELECT, còn @ManyToOne PaymentMethod thì bảng orders chỉ giữ khoá ngoại và dữ liệu nằm ở bảng riêng, cần JOIN mới lấy được

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
0/5 câu đã trả lời
  1. 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 }.
  2. Q2
    Field private Instant createdAt trong entity có cần @Column(name = "created_at") không? Naming strategy xử lý thế nào?
  3. 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ì?
  4. 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?
  5. 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;
    }

Bài tiếp theo: Lifecycle callbacks & equals/hashCode

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

Entity lifecycle & equals/hashCode — @PrePersist, @PostLoad, và pitfall Lombok @Data