Domain-Driven Design chia làm hai phần: strategic (bounded context, ubiquitous language — chuyện tổ chức) và tactical (các khối xây dựng mã: value object, entity, aggregate). Phần tactical thường được minh họa bằng Java/C# hướng đối tượng, nhưng Go — với value semantics của struct và không có class kế thừa — thể hiện chúng theo cách riêng, đôi khi gọn hơn. Bài này dựng ba khối cốt lõi chạy được, đo thật hành vi của chúng, và chỉ ra chỗ Go làm khác ngôn ngữ OOP truyền thống.

Value Object: bất biến, so theo giá trị

Value object là thứ không có định danh — nó chính là giá trị của nó. Tiền 100 VND bằng mọi 100 VND khác, bất kể tạo lúc nào. Nó phải bất biến: mọi phép toán trả về bản mới thay vì sửa tại chỗ. Trong Go, struct với các trường comparable là ứng viên hoàn hảo:

type Tien struct{ xu int; tienTe string } // lưu xu tránh sai số float

func (t Tien) Cong(o Tien) (Tien, error) {
	if t.tienTe != o.tienTe { return Tien{}, errors.New("khác loại tiền tệ") }
	return Tien{xu: t.xu + o.xu, tienTe: t.tienTe}, nil // trả BẢN MỚI
}

Điểm Go làm khác OOP truyền thống: không cần viết equals()/hashCode(). Go so sánh struct bằng == theo giá trị sẵn, và một struct comparable dùng làm map key được ngay. Đây là value semantics — thứ Java phải mô phỏng bằng tay thì Go cho miễn phí.

Ảnh chụp đoạn mã Go nền tối minh hoạ DDD tactical trong Go value object entity aggregate, value object bất biến so theo giá trị không định danh type Tien struct xu int tienTe string lưu xu tránh sai số float func t Tien Cong o Tien trả Tien error nếu t tienTe khác o tienTe return Tien rỗng err return Tien xu t xu cộng o xu tienTe t tienTe nil trả bản mới Go struct các trường comparable dấu bằng bằng so theo giá trị làm map key được, entity có định danh so theo ID không theo giá trị type DongHang struct ID int định danh hai DongHang cùng ID là một SanPham string SoLuong int DonGia Tien entity chứa value object e1 ID bằng e2 ID cùng thực thể dù thuộc tính khác, aggregate root bảo vệ bất biến của cả cụm type DonHang struct ID int dong slice DongHang trường riêng chỉ sửa qua root tran Tien bất biến tổng nhỏ hơn bằng trần func d con trỏ DonHang ThemDong dh DongHang error nếu sau xu lớn hơn d tran xu return ErrVuotHanMuc root ép luật d dong bằng append d dong dh return nil, khác OOP truyền thống value object Go dùng struct cộng method value receiver dấu bằng bằng miễn phí không cần equals hashCode comparable struct làm map key sẵn bất biến bằng quy ước trả bản mới không có final readonly aggregate đóng gói qua trường thường chữ nhỏ trong package

Hình 1: Value object Tien (bất biến, Cong trả bản mới); Entity DongHang (định danh qua ID, chứa value object); Aggregate DonHang (trường dong riêng, root ThemDong ép bất biến). Go dùng struct value semantics thay cho equals()/hashCode().

Entity: định danh qua ID

Entity là thứ có định danh — hai entity cùng ID là cùng một thực thể dù thuộc tính khác nhau (một khách hàng đổi tên vẫn là khách hàng đó). Entity so sánh theo ID, không theo giá trị:

type DongHang struct {
	ID      int    // định danh
	SanPham string
	SoLuong int
	DonGia  Tien   // entity CHỨA value object
}

Đo thật (Go 1.23) làm rõ khác biệt then chốt giữa hai khối:

[Entity] cùng ID 5? true  -> CÙNG thực thể (dù thuộc tính khác)
[Value] 10 == 20? false  -> KHÁC value (so theo giá trị)
[Value] 10 == 10? true   -> CÙNG value (bất kể tạo riêng)

Hai DongHang cùng ID=5 là cùng thực thể dù SanPham và SoLuong khác nhau — bạn so e1.ID == e2.ID. Ngược lại, hai Tien bằng nhau khi và chỉ khi giá trị bằng nhau. Đây là ranh giới quan trọng nhất trong tactical DDD: hỏi "cái này có định danh không?" để quyết định entity hay value object.

Aggregate: root ép bất biến

Aggregate là một cụm entity/value object được xem như một đơn vị nhất quán, với một aggregate root là cửa duy nhất để sửa. Root chịu trách nhiệm ép bất biến (invariant) của cả cụm — luật luôn phải đúng:

type DonHang struct {
	ID   int
	dong []DongHang // TRƯỜNG RIÊNG (chữ thường) — chỉ sửa qua root
	tran Tien       // bất biến: tổng <= trần
}

func (d *DonHang) ThemDong(dh DongHang) error {
	// ... tính tổng sau khi thêm ...
	if sau.xu > d.tran.xu { return ErrVuotHanMuc } // ROOT ép luật
	d.dong = append(d.dong, dh)
	return nil
}

Ảnh chụp bảng kết quả đo thật nền tối value object so theo giá trị entity theo ID aggregate root ép bất biến, go run Go 1.23 arm64 10 core, value object so sánh theo giá trị bất biến Hai Tien bằng nhau theo giá trị true dấu bằng bằng so theo giá trị 100 cộng 100 bằng 200.00 VND Cong trả bản mới Cộng khác tiền tệ khác loại tiền tệ value object tự bảo vệ, entity theo ID vs value object theo giá trị Entity cùng ID 5 true cùng thực thể dù thuộc tính khác Value 10 bằng bằng 20 false khác value so theo giá trị Value 10 bằng bằng 10 true cùng value bất kể tạo riêng entity định danh là ID value object chính là giá trị của nó, aggregate root ép bất biến tổng nhỏ hơn bằng trần 1000 Thêm dòng 300 nil OK tổng 300 Thêm dòng 800 đơn vượt hạn mức 300 cộng 800 bằng 1100 lớn hơn 1000 từ chối Tổng đơn 300.00 VND bất biến giữ vững root là cửa duy nhất sửa cụm không thể phá bất biến từ ngoài, bonus value object comparable làm map key map Tien int có 2 khóa khác nhau 10VND gộp 10VND cộng 10VND cùng khóa 10USD khóa riêng Go cho miễn phí, cốt lõi value object bất biến so theo giá trị dấu bằng bằng của Go làm map key entity có định danh ID so theo ID không theo thuộc tính aggregate root là cửa duy nhất sửa ép bất biến của cả cụm Go riêng struct value semantics cho value object gần như miễn phí đánh đổi đừng nhét mọi thứ vào một aggregate khổng lồ

Hình 2: Value object so theo giá trị (true), tự bảo vệ (từ chối cộng khác tiền tệ). Entity so theo ID; value object so theo giá trị. Aggregate root từ chối dòng 800 (300+800=1100 > trần 1000), tổng giữ ở 300. Value object comparable làm map key: 2 khóa (10VND gộp).

Đo thật: thêm dòng 300 OK (tổng 300), thêm dòng 800 bị từ chối (đơn vượt hạn mức, vì 300+800=1100 > trần 1000), tổng giữ vững ở 300. Vì trường dong là chữ thường (private trong package), không code ngoài nào sửa được slice trực tiếp — mọi thay đổi phải qua ThemDong, nơi bất biến được kiểm. Đây là cách Go đóng gói aggregate: dùng khả năng hiển thị cấp package thay cho private của class. Bonus đo thật: map[Tien]int có 2 khóa khác nhau khi thêm 10VND (hai lần, gộp), 10VND lại và 10USD — comparable struct làm map key miễn phí.

Đánh đổi cần cân nhắc

Đừng nhét mọi thứ vào một aggregate khổng lồ. Cám dỗ phổ biến là làm một aggregate KhachHang chứa mọi đơn hàng, địa chỉ, lịch sử. Aggregate lớn gây khóa tranh chấp (mọi thay đổi khóa cả cụm) và tải bộ nhớ nặng. Nguyên tắc: aggregate nên nhỏ nhất có thể mà vẫn giữ được bất biến. Thứ không cần nhất quán tức thì nên là aggregate riêng, tham chiếu nhau bằng ID chứ không nhúng.

Value semantics của Go giúp value object nhưng cẩn thận con trỏ và slice. Struct chỉ comparable (và làm map key được) khi mọi trường comparable — thêm một trường []byte hay map là mất tính đó, == không biên dịch. Với value object có trường không comparable, phải tự viết phương thức BangNhau. Và nhớ: value object nên bất biến, nên tránh trường con trỏ/slice mà người ngoài có thể sửa qua tham chiếu chung.

DDD tactical là công cụ, không phải luật. Không phải mọi struct đều cần là entity hay value object có phương thức. Với CRUD đơn giản, một struct dữ liệu phẳng đọc rõ hơn nhiều lớp trừu tượng. DDD trả cổ tức khi domain phức tạp về nghiệp vụ — nhiều bất biến, nhiều luật, nhiều thuật ngữ chuyên ngành. Áp nó cho domain đơn giản chỉ thêm nghi thức.

Ba ý mang về

  1. Value object bất biến và so theo giá trị: trong Go, struct với các trường comparable so bằng == theo giá trị sẵn (đo thật 10 == 10 là true dù tạo riêng) và làm map key được — không cần equals()/hashCode() như OOP truyền thống; mọi phép toán trả bản mới.
  2. Entity có định danh, so theo ID: hai entity cùng ID là cùng thực thể dù thuộc tính khác (đo thật e1.ID == e2.ID true) — câu hỏi "có định danh không?" quyết định entity hay value object.
  3. Aggregate root là cửa duy nhất ép bất biến: đo thật, root từ chối thao tác phá bất biến (dòng 800 vượt trần 1000), và trường private cấp package chặn sửa trực tiếp từ ngoài — nhưng giữ aggregate nhỏ nhất có thể, và DDD chỉ đáng khi domain thật sự phức tạp.

Phần sau ta quay về kỹ thuật web thực dụng với một mẫu Go dùng khắp nơi: Phần sau mổ xẻ middleware chain có thể kết hợp — cách xâu chuỗi các lớp xử lý HTTP bằng hàm bậc cao, và làm nó gọn mà vẫn linh hoạt.