Hầu hết hệ thống đơn hàng mà ta gặp đều có chung một triệu chứng: các lớp Order, Customer, Product chỉ là túi chứa getter và setter, còn mọi quy tắc nghiệp vụ nằm rải rác ở service. Số tiền là double, số điện thoại là String, và bất kỳ đoạn code nào cũng có thể gọi order.getLines().add(...) để phá vỡ quy tắc "đơn đã xác nhận thì không sửa". Domain-Driven Design (DDD) đưa ra bộ khối xây dựng chiến thuật để chấm dứt tình trạng này, trong đó Value Object, Entity và Aggregate là ba khối nền tảng. Bài này giải thích từng khái niệm bằng code Java 21 (record, JPA/Hibernate) chạy thật, chỉ ra chỗ nào dễ nhầm và khi nào DDD là quá tay.

Bài nối tiếp Specification pattern: truy vấn động với Spring Data JPA và Repository pattern và Unit of Work với JPA: repository trả về Aggregate, specification mô tả điều kiện lọc, còn bài này đi vào chính cấu trúc của đối tượng miền được lưu trữ.

Vấn đề: mô hình miền thiếu máu và vai trò của Value Object

Martin Fowler gọi lối thiết kế chỉ có dữ liệu mà không có hành vi là "anemic domain model". Với đơn hàng, nó thể hiện như sau:

  • Mọi thuộc tính đều có setter công khai, nên trạng thái hợp lệ chỉ được đảm bảo bằng thói quen của lập trình viên.
  • Kiểm tra hợp lệ lặp lại ở controller, service, batch job; mỗi nơi kiểm tra một kiểu và có chỗ quên.
  • Kiểu nguyên thuỷ (String, double) mang nghĩa nghiệp vụ nhưng trình biên dịch không phân biệt được "số điện thoại" với "mã đơn".

DDD đề xuất kéo quy tắc về lại đối tượng miền, qua ba khối: đối tượng có định danh (Entity), đối tượng chỉ là giá trị (Value Object), và cụm đối tượng có một cổng vào duy nhất (Aggregate).

Entity: định danh quyết định, thuộc tính có thể đổi

Entity là đối tượng có định danh xuyên suốt vòng đời: đơn hàng DH001 hôm nay đổi địa chỉ giao vẫn là đơn DH001. Hai Entity bằng nhau khi cùng định danh, bất kể thuộc tính khác nhau. Trong code, điều này nghĩa là equals và hashCode chỉ dựa trên id:

@Override public boolean equals(Object o) { return o instanceof Order other && id.equals(other.id); }
@Override public int hashCode() { return id.hashCode(); }

Một lưu ý thực tế: nếu id do database sinh (IDENTITY), id là null trước khi lưu và hashCode sẽ đổi sau khi lưu, làm hỏng các HashSet. Bài này gán id ngay khi tạo bằng UUID để tránh vấn đề đó. Kết quả chạy thật (code/s6-23, Spring Boot 3.3.5, Hibernate 6.5.3, H2 trong bộ nhớ):

=== 1. Entity: bằng nhau theo id ===
Cùng id, khác địa chỉ: equals = true
Khác id, cùng dữ liệu: equals = false

Value Object là gì và vì sao nên dùng nhiều hơn bạn nghĩ

Theo bài viết về Value Object của Martin Fowler, Value Object là đối tượng mà định danh đến từ giá trị các thuộc tính ("equal due to the value of their properties"), và nên bất biến: muốn "đổi" thì tạo đối tượng mới. Fowler cũng cảnh báo về lỗi aliasing, khi hai biến cùng trỏ vào một đối tượng khả biến và thay đổi qua biến này làm hỏng biến kia. Ba đặc điểm của một Value Object:

  • Bất biến: mọi trường là final, không có setter; phép toán trả về đối tượng mới.
  • So sánh theo giá trị: equals/hashCode dựa trên toàn bộ thuộc tính.
  • Tự kiểm tra bất biến khi tạo: không thể tồn tại một Address thiếu thành phố hay một Money lệch đơn vị.

Từ Java 16, record cho cả ba đặc điểm gần như miễn phí: trường final, equals/hashCode/toString sinh sẵn, và compact constructor để kiểm tra. Điều này khiến việc tạo một Value Object chỉ tốn vài dòng. Đây cũng là nơi các bài trước về Builder pattern với record gặp DDD.

Ví dụ Value Object: Money

Tiền là ví dụ kinh điển của Value Object. Vì sao không dùng double? Chạy thật để thấy (file plain/Main1.java):

0.1 + 0.2 (double) = 0.30000000000000004
0.1 + 0.2 (BigDecimal) = 0.3
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625

BigDecimal giải quyết sai số nhị phân, nhưng có một bẫy khác: equals của BigDecimal so sánh cả scale.

2.0 equals 2.00    : false
2.0 compareTo 2.00 : 0
HashSet chứa 2.00? : false

Nghĩa là 150000 và 150000.00 được coi là hai giá trị khác nhau trong Set và Map, dù cùng số tiền. Value Object Money giải quyết bằng cách chuẩn hoá scale ngay trong constructor theo Currency.getDefaultFractionDigits() (0 cho VND, 2 cho USD):

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        int scale = Math.max(currency.getDefaultFractionDigits(), 0);
        if (amount.stripTrailingZeros().scale() > scale) {
            throw new IllegalArgumentException("Số tiền có quá " + scale + " chữ số thập phân: " + amount);
        }
        amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
    }

    public static Money vnd(long value) { return new Money(BigDecimal.valueOf(value), Currency.getInstance("VND")); }
    public static Money usd(String value) { return new Money(new BigDecimal(value), Currency.getInstance("USD")); }

    public Money plus(Money other) {
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }
    public Money times(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("Khác tiền tệ: " + currency + " và " + other.currency);
    }
    @Override public String toString() { return amount.toPlainString() + " " + currency.getCurrencyCode(); }
}

Đoạn trên đã có đủ phương thức để chạy ví dụ bên dưới (file code/s6-23/plain/Money.java, kèm các import thông thường của java.math, java.util.Currency và java.util.Objects). Khi dùng với JPA ở phần sau, bản ánh xạ thêm @Embeddable lên record này.

Kết quả chạy thật:

150000 VND equals 150000 VND : true, cùng hashCode: true
150000 VND == 150000.00 VND : true
plus : 200000 VND
times: 450000 VND
Lỗi: Khác tiền tệ: VND và USD
Lỗi: Số tiền có quá 0 chữ số thập phân: 10.5
USD 12.5 -> 12.50 USD

Lợi ích thấy ngay: không thể cộng VND với USD, không thể tạo VND có phần lẻ, và 150000 bằng 150000.00. Các quy tắc đó nằm đúng một nơi thay vì rải ở mọi service. Trong tiền thật, hãy cân nhắc thêm quy tắc làm tròn rõ ràng cho phép chia (ví dụ chia hoá đơn), vì RoundingMode.UNNECESSARY chỉ phù hợp cho cộng và nhân số nguyên.

Thêm hai Value Object: Address và OrderLine

Địa chỉ giao hàng là Value Object, không có định danh: đổi đường là đổi cả giá trị. Dòng hàng trong đơn cũng là Value Object, vì ta không quan tâm "dòng hàng số 3 của đơn" như một thực thể riêng mà chỉ quan tâm SKU, số lượng, đơn giá:

@Embeddable
public record Address(String street, String city, String phone) {
    public Address {
        if (street == null || street.isBlank()) throw new IllegalArgumentException("Thiếu địa chỉ");
        if (city == null || city.isBlank()) throw new IllegalArgumentException("Thiếu thành phố");
        if (phone == null || !phone.matches("0\\d{9}"))
            throw new IllegalArgumentException("Số điện thoại không hợp lệ: " + phone);
    }
}

@Embeddable
public record OrderLine(String sku, int quantity, Money unitPrice) {
    public OrderLine {
        if (sku == null || sku.isBlank()) throw new IllegalArgumentException("Thiếu SKU");
        if (quantity <= 0) throw new IllegalArgumentException("Số lượng phải > 0");
    }
    public Money subtotal() { return unitPrice.times(quantity); }
}

Biểu thức 0\d{9} chỉ minh hoạ: số điện thoại thật có nhiều đầu số và định dạng quốc tế, nên hãy dùng thư viện chuyên dụng khi cần chính xác.

Aggregate: một cổng vào, một ranh giới nhất quán

Theo định nghĩa Aggregate trên martinfowler.com, Aggregate là một cụm đối tượng miền được xử lý như một đơn vị, ví dụ đơn hàng cùng các dòng hàng. Một đối tượng là root; mọi tham chiếu từ bên ngoài chỉ được trỏ tới root, và transaction không nên vắt ngang nhiều Aggregate. Từ đó có bốn quy tắc thiết kế thực dụng:

  • Bảo vệ bất biến bên trong root: mọi thay đổi đi qua phương thức của root (addLine, confirm), không có setter công khai.
  • Không lộ tập hợp khả biến: getLines() trả về view chỉ đọc.
  • Tham chiếu Aggregate khác bằng id: Order giữ customerId chứ không giữ object Customer, nên ranh giới tải và transaction rõ ràng.
  • Aggregate nhỏ: chỉ chứa những gì phải nhất quán tức thì cùng nhau.
@Entity @Table(name = "orders")
public class Order {
    static final int MAX_LINES = 5;
    @Id private UUID id;
    private Long customerId;
    @Enumerated(EnumType.STRING) private OrderStatus status;
    @Embedded private Address shipTo;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
    private List<OrderLine> lines = new ArrayList<>();

    public void addLine(String sku, int quantity, Money unitPrice) {
        requireStatus(OrderStatus.DRAFT, "thêm dòng hàng");
        if (lines.size() >= MAX_LINES) throw new IllegalStateException("Đơn tối đa " + MAX_LINES + " dòng hàng");
        lines.add(new OrderLine(sku, quantity, unitPrice));
    }

    public void confirm() {
        requireStatus(OrderStatus.DRAFT, "xác nhận");
        if (lines.isEmpty()) throw new IllegalStateException("Không thể xác nhận đơn rỗng");
        status = OrderStatus.CONFIRMED;
    }
    public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); }
}

Chạy thật kịch bản cố tình phá bất biến, mỗi dòng [chặn] là một vi phạm bị root từ chối:

[chặn] Xác nhận đơn rỗng -> IllegalStateException: Không thể xác nhận đơn rỗng
Tổng tiền: 620000 VND
[chặn] Sửa danh sách dòng hàng từ bên ngoài -> UnsupportedOperationException: null
[chặn] Số lượng 0 -> IllegalArgumentException: Số lượng phải > 0
[chặn] Số điện thoại sai -> IllegalArgumentException: Số điện thoại không hợp lệ: 123
[chặn] Thêm dòng sau khi đã xác nhận -> IllegalStateException: Không thể thêm dòng hàng khi đơn ở trạng thái CONFIRMED
[chặn] Dòng hàng thứ 6 -> IllegalStateException: Đơn tối đa 5 dòng hàng

Vòng đời DRAFT → CONFIRMED → SHIPPED thực chất là một máy trạng thái nhỏ; khi số trạng thái và chuyển tiếp tăng lên, hãy tách ra theo hướng dẫn ở bài State pattern và máy trạng thái cho đơn hàng.

Ánh xạ Value Object sang JPA bằng @Embeddable

Theo tài liệu Hibernate 6.5, một embeddable ánh xạ trạng thái vào nhiều cột của bảng chủ, không có định danh riêng, vòng đời đi theo entity chứa nó; và Java record có thể làm embeddable mà không cần constructor rỗng. Chạy thật, Hibernate sinh DDL như sau:

create table order_lines (amount numeric(38,2), currency varchar(3), quantity integer, order_id uuid not null, sku varchar(255))
create table orders (customer_id bigint, id uuid not null, city varchar(255), phone varchar(255), street varchar(255), status enum ('CONFIRMED','DRAFT','SHIPPED'), primary key (id))

Value Object Address được "làm phẳng" thành ba cột street, city, phone trong bảng orders. Money lồng trong OrderLine thành hai cột amount, currency. Danh sách Value Object OrderLine qua @ElementCollection nằm ở bảng order_lines không có khoá chính riêng, thể hiện đúng bản chất "không có định danh". Lưu rồi nạp lại:

insert into orders (customer_id,street,city,phone,status,id) values (?,?,?,?,?,?)
insert into order_lines (order_id,sku,quantity,amount,currency) values (?,?,?,?,?)
Trạng thái: CONFIRMED, tổng: 620000 VND, địa chỉ: Address[street=12 Trần Duy Hưng, city=Hà Nội, phone=0901234567]
Dòng hàng : [OrderLine[sku=AO-01, quantity=2, unitPrice=250000 VND], OrderLine[sku=NON-05, quantity=1, unitPrice=120000 VND]]

Hai điểm cần cân nhắc khi lưu Value Object bằng @ElementCollection. Thứ nhất, khi danh sách thay đổi, Hibernate thường xoá toàn bộ dòng của Aggregate rồi chèn lại, chấp nhận được khi Aggregate nhỏ như đơn hàng nhưng không nên áp dụng cho tập hợp hàng nghìn phần tử. Thứ hai, vì trường amount được ánh xạ numeric(38,2) theo mặc định, hãy khai báo @Column(precision, scale) rõ ràng nếu cần khớp với schema sẵn có.

Cách JDK và framework dùng Value Object

Bạn đã dùng Value Object hằng ngày mà không gọi tên: String, Integer, BigDecimal, LocalDate, Duration trong java.time đều bất biến và so sánh theo giá trị. Javadoc của java.time mô tả rõ các lớp này là value-based, nghĩa là nên so sánh bằng equals, không nên dùng làm khoá đồng bộ hoá. Phép toán trả về bản mới (date.plusDays(1)) chính là cách thao tác mà Money.plus noi theo. Trong Spring, @Embeddable của JPA, @Value của Lombok hay record của Java đều phục vụ cùng ý tưởng.

Khi nào không nên dùng Value Object và DDD chiến thuật

DDD không miễn phí. Các tình huống nên cân nhắc lại:

  • CRUD thuần: bảng danh mục, cấu hình không có quy tắc nghiệp vụ thì mô hình thiếu máu là đủ.
  • Báo cáo và truy vấn đọc: nạp cả Aggregate chỉ để hiển thị danh sách là lãng phí; dùng projection hoặc truy vấn đọc riêng.
  • Aggregate quá lớn: gom Customer, toàn bộ Order, toàn bộ Payment vào một Aggregate sẽ gây xung đột ghi đồng thời và tải nặng. Hãy chia theo ranh giới nhất quán thực sự. Với Aggregate dùng chung giữa nhiều người dùng, thêm trường @Version ở Aggregate Root để JPA bật khoá lạc quan: hai giao dịch sửa cùng đơn thì giao dịch sau bị OptimisticLockException thay vì ghi đè âm thầm (đây là cơ chế chuẩn của JPA, chưa chạy trong demo của bài).
  • Ranh giới Aggregate tuỳ ngữ cảnh: cùng một khái niệm có thể là Entity ở ngữ cảnh này và Value Object ở ngữ cảnh khác. Evans lưu ý rằng Address là Value Object với hệ thống bán hàng, nhưng lại là Entity với công ty điện lực cần theo dõi từng địa chỉ. Đừng áp quy tắc máy móc, hãy hỏi nghiệp vụ.
  • Đội chưa có ngôn ngữ chung với nghiệp vụ: giá trị lớn nhất của DDD là ngôn ngữ chung (ubiquitous language), không phải annotation.

Lỗi thường gặp

  • Value Object khả biến: thêm setter vào Address sẽ quay lại lỗi aliasing mà Fowler mô tả.
  • Entity dùng equals theo mọi trường: hai bản sao cùng id nhưng khác thuộc tính bị coi là khác nhau, hỏng Set và cache.
  • Id do database sinh làm hashCode đổi: gán id sớm (UUID, ULID) hoặc dùng quy ước riêng.
  • Lộ collection khả biến: trả về List gốc cho phép bên ngoài bỏ qua mọi kiểm tra. Dùng Collections.unmodifiableList hoặc List.copyOf.
  • Aggregate giữ object của Aggregate khác: kéo theo tải lazy khó kiểm soát và transaction chồng lấn; hãy giữ id.
  • Kiểm tra chỉ ở tầng API: bất biến của Value Object cần nằm trong miền để batch, message consumer cũng được bảo vệ.

Checklist thiết kế Value Object và Aggregate

  • Khái niệm nào chỉ cần giá trị (tiền, địa chỉ, khoảng ngày) thì thành Value Object bằng record; mỗi Value Object tự kiểm tra hợp lệ.
  • Kiểm tra hợp lệ trong compact constructor để không tồn tại giá trị sai.
  • Chuẩn hoá để equals phản ánh đúng ý nghĩa (ví dụ scale của BigDecimal).
  • Entity so sánh theo id, id gán ngay khi tạo.
  • Mọi thay đổi Aggregate đi qua root; không có setter công khai; collection trả về chỉ đọc.
  • Aggregate nhỏ, tham chiếu nhau bằng id, một transaction cho một Aggregate.
  • Dùng @Embeddable và @ElementCollection cho Value Object khi ánh xạ JPA, và kiểm tra DDL sinh ra.

Tóm lại, Value Object biến những kiểu nguyên thuỷ mơ hồ thành khái niệm miền tự bảo vệ, còn Aggregate gom các khái niệm đó thành đơn vị nhất quán với một cổng vào. Cả hai đều không cần framework đặc biệt: Java 21 và JPA đã đủ.

Bài tiếp theo

Khi miền đã tự bảo vệ, câu hỏi kế tiếp là đặt nó ở đâu trong kiến trúc ứng dụng để không bị framework và database kéo lệch. Mời bạn đọc tiếp Hexagonal Architecture với Spring Boot: port và adapter.