Entity กับ identity ที่คงอยู่
ใน Course A บทที่ 2 เราเจอ OrderLine เป็นครั้งแรก ตอนนั้นมันถูกประกาศเป็น record ตัวเดียวกับที่ Money/Quantity ใช้ — บทที่แล้วของคอร์สนี้เราขุดลึก Money/Quantity ในฐานะ value objectValue Objectอ็อบเจ็กต์ที่นิยามด้วย 'ค่า' ไม่มี identity ของตัวเอง สองตัวที่ค่าเท่ากันถือว่าเท่ากัน ควร immutable เช่น Money, Address, QuantityTactical Design ไปแล้วว่าทำไม record ถึงเหมาะกับมันมาก บทนี้เราจะพลิกมุมกลับด้าน: OrderLine ก็เป็น record เหมือนกัน แต่ record ไม่ใช่ ทางเลือกที่ถูกต้องสำหรับมัน เพราะ OrderLine ไม่ใช่ value object — มันคือ entityEntityอ็อบเจ็กต์ที่มี 'ตัวตน' (identity) ต่อเนื่องตามเวลา แม้ค่าภายในเปลี่ยนก็ยังเป็นสิ่งเดิม เทียบกันด้วย id ไม่ใช่ด้วยค่า เช่น Order, OrderLineTactical Design
Entity คืออ็อบเจ็กต์ที่มี identityIdentity'ตัวตน' ที่ทำให้ Entity เป็นสิ่งเดิมตลอดอายุแม้ค่าเปลี่ยน เช่น OrderId — ใน code คือการเทียบเท่ากันด้วย id ล้วน ๆ ไม่ใช่ด้วย field ทั้งหมดTactical Design — ตัวตนที่คงอยู่ต่อเนื่องตามเวลา แม้ค่าข้างในจะเปลี่ยนไปแค่ไหนก็ตาม สิ่งที่ทำให้2 entity เป็น “ตัวเดียวกัน” หรือ “คนละตัว” คือการเทียบกันด้วย Id เท่านั้น ไม่ใช่การเทียบค่าทุก field แบบที่ value object ทำ — นี่คือเส้นแบ่งที่ชัดที่สุดระหว่าง2 building block นี้ และบทนี้จะพาไปดูว่าทำไมเส้นแบ่งนี้ถึงสำคัญพอที่จะทำให้ record กลายเป็นกับดักได้
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Orders/OrderLine.cs และ FoodOrdering.Domain/Common/Entity.cs (ใหม่ในบทนี้)
ก่อนไปต่อ ทวน id ทั้งสามตัวที่ Course A ประกาศไว้ตั้งแต่บทที่ 2 ก่อน — จะใช้ซ้ำหลายจุดในบทนี้ รวมถึงตัวอย่าง bug ที่กำลังจะเห็นด้านล่าง:
// FoodOrdering.Domain/Orders/ (จาก Course A บทที่ 2)public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);OrderLine ที่ Course A ส่งมา: record ที่ดูปลอดภัยแต่ไม่ใช่
หัวข้อที่มีชื่อว่า “OrderLine ที่ Course A ส่งมา: record ที่ดูปลอดภัยแต่ไม่ใช่”ทวน OrderLine ที่ Course A ประกาศไว้ตั้งแต่บทที่ 2 — ตอนนั้นมันดูสมเหตุสมผลมาก เพราะเพิ่งเห็น Money/Quantity เป็น record มาสดๆ เลยจับ pattern เดียวกันมาใช้กับ OrderLine ด้วย:
// FoodOrdering.Domain/Orders/OrderLine.cs (จาก Course A บทที่ 2)public sealed record OrderLine(OrderLineId Id, ProductId ProductId, Quantity Quantity, Money UnitPrice);ดูเผินๆ ไม่มีอะไรผิด — มันมี Id อยู่แล้วด้วยซ้ำ! ปัญหาคือ record ให้ value equality แบบเทียบทุก field เท่ากันหมด — Id เป็นแค่หนึ่งใน4 field ไม่ได้ถูกยกให้มีน้ำหนักพิเศษกว่า field อื่นเลยสักนิด ลองดูจุดที่มันพังตอนเขียน feature “สั่งซ้ำจากใบเสร็จเดิม” (reorder) — สร้าง OrderLine จากรายการเดิม ก่อนจะรู้ id จริงจาก DB:
var line1 = new OrderLine(new OrderLineId(Guid.Empty), productId, new Quantity(1), unitPrice); // ผัดไทย 1 จานvar line2 = new OrderLine(new OrderLineId(Guid.Empty), productId, new Quantity(1), unitPrice); // ลูกค้ากดสั่งผัดไทยเพิ่มอีก 1 จาน แยกบรรทัด (อยากได้สองจานแยกกัน)
var uniqueLines = new List<OrderLine> { line1, line2 }.Distinct().ToList();// uniqueLines.Count == 1 !! — record มองว่า line1 กับ line2 "เท่ากัน" เพราะทุก field ตรงกันหมด// (รวม Id ที่ยังเป็น Guid.Empty เหมือนกันทั้งคู่ ณ จุดนี้)// ลูกค้าจ่ายเงิน 2 จาน แต่ระบบเหลือรายการเดียว — ครัวทำแค่จานเดียวต้นตอไม่ใช่แค่ “ลืม generate id ให้ไม่ซ้ำ” — แต่คือ record ไม่มีทางสื่อสารได้เลยว่า Id ควรเป็นแกนเดียวที่ใช้เทียบความเท่ากัน code ข้างบน compile ผ่านสบายๆ และ Distinct() ก็ทำงานตาม contract ของ record ถูกต้องทุกอย่าง — ปัญหาอยู่ที่ type ทั้งตัวเลือกใช้ semantics ผิดตั้งแต่แรก สองบรรทัดที่ธุรกิจตั้งใจให้เป็นคนละรายการ กลับถูกมองว่าเป็นตัวเดียวกันเพียงเพราะค่าทุก field บังเอิญเท่ากัน
ยกระดับ OrderLine ให้เทียบด้วย identity จริง
หัวข้อที่มีชื่อว่า “ยกระดับ OrderLine ให้เทียบด้วย identity จริง”ทางแก้ไม่ใช่การจำไว้เองว่า “อย่าลืม generate Id ให้ไม่ซ้ำ” — นั่นแก้ที่ปลายเหตุ ทางแก้ที่ตรงจุดคือเปลี่ยน semantics ของความเท่ากันทั้งระบบ ให้ entity ทุกตัวเทียบกันด้วย Id เพียงอย่างเดียวเสมอ ไม่ว่า field อื่นจะเหมือนหรือต่างกันแค่ไหน เราทำสิ่งนี้ได้ด้วย base class กลางตัวเดียว แล้วให้ entity ทุกตัวสืบทอดจากมัน:
// FoodOrdering.Domain/Common/Entity.cs (ใหม่ในบทนี้)public abstract class Entity<TId> : IEquatable<Entity<TId>> where TId : notnull{ public TId Id { get; }
protected Entity(TId id) { Id = id; }
public bool Equals(Entity<TId>? other) { if (other is null) return false; if (ReferenceEquals(this, other)) return true; if (GetType() != other.GetType()) return false; return Id.Equals(other.Id); }
public override bool Equals(object? obj) => Equals(obj as Entity<TId>);
public override int GetHashCode() => Id.GetHashCode();
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) => left is null ? right is null : left.Equals(right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) => !(left == right);}Equals เช็กแค่สองอย่าง: type ตรงกันไหม (กัน OrderLine ไปเทียบเท่ากับ entity อื่นที่บังเอิญใช้ TId ชนิดเดียวกัน) แล้วก็ Id.Equals(other.Id) เท่านั้น — ไม่แตะ ProductId, Quantity, UnitPrice เลยสัก field จากนั้น OrderLine ก็เปลี่ยนจาก record มาเป็น class ที่สืบทอด Entity<OrderLineId> แทน:
// FoodOrdering.Domain/Orders/OrderLine.cs (ยกระดับในบทนี้)public sealed class OrderLine : Entity<OrderLineId>{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}ลองรัน code reorder เดิมซ้ำอีกครั้งด้วย OrderLine version ใหม่นี้ — ถ้า line1/line2 ยังใช้ OrderLineId(Guid.Empty) เหมือนกันทั้งคู่ Equals ก็ยังบอกว่าเท่ากันอยู่ดี (เพราะ Id เหมือนกันจริงๆ ตามที่มันควรจะเป็น) — แต่ตอนนี้ bug มันฟ้องตรงจุดที่ควรฟ้อง: ปัญหาย้ายจาก “record เทียบผิด field” ไปเป็น “ต้อง generate OrderLineId ให้ไม่ซ้ำกันตั้งแต่ตอนสร้าง” (เช่นเรียก new OrderLineId(Guid.NewGuid()) ทุกครั้งที่เพิ่มบรรทัดใหม่) ซึ่งเป็นคำถามที่ตรงประเด็นและแก้ได้ตรงจุดจริงๆ ไม่ใช่ปัญหาที่ซ่อนอยู่ใน field อื่นที่ไม่เกี่ยวกับตัวตนเลยแบบเมื่อกี้
classDiagram
class Entity~TId~ {
+TId Id
+Equals(object obj) bool
+GetHashCode() int
}
class OrderLine {
+ProductId ProductId
+Quantity Quantity
+Money UnitPrice
}
class Money {
+decimal Amount
+string Currency
}
Entity~TId~ <|-- OrderLine : เทียบด้วย Id เท่านั้น
note for Money "เทียบด้วยค่า (value equality) ทุก field"
คำบรรยายภาพ: OrderLine สืบทอดจาก Entity<TId> จึงเทียบกันด้วย Id เพียงอย่างเดียว ต่างจาก Money ที่เป็น value object เทียบกันด้วยค่าทุก field
ทำไม record ถึงเหมาะกับ id แต่ไม่เหมาะกับ entity
หัวข้อที่มีชื่อว่า “ทำไม record ถึงเหมาะกับ id แต่ไม่เหมาะกับ entity”สังเกตว่า OrderLineId ที่เพิ่งใช้เป็น parameter ของ Entity<OrderLineId> ด้านบนนั้น ยังเป็น record เหมือนเดิมไม่มีเปลี่ยน — และนั่นถูกต้องแล้ว ทวน id ทั้งสามตัว (OrderId, OrderLineId, ProductId) ที่เพิ่งเห็นไปตอนต้นบทอีกครั้ง — ทั้งสามตัวยังเป็น record เหมือนเดิมทุกประการ ไม่มีอะไรเปลี่ยนไปจาก Course A บทที่ 2 เลย
OrderLineId เองไม่ใช่ entity — มันคือ value object ที่ห่อ Guid เปล่าๆ ให้มีความหมาย และมันควรเป็น record จริงๆ เพราะ id คือค่า ไม่ใช่สิ่งที่มีตัวตนของตัวเองอีกที: OrderLineId สองตัวที่ห่อ Guid ค่าเดียวกัน สร้างขึ้นแยกกันคนละที่ ก็ยังหมายถึง “id เดียวกัน” เสมอ ไม่ต้องมีอะไรมาแยกความแตกต่างระหว่างมัน — สมบัติของ record (value equality + immutableImmutableเปลี่ยนแปลงไม่ได้หลังสร้าง ถ้าจะ 'เปลี่ยนค่า' ต้องสร้างอ็อบเจ็กต์ใหม่แทน หลักการสำคัญของ Value Object เพื่อเลี่ยง bug จากการใช้อ้างอิงร่วมกันTactical Design) จึงเหมาะกับ id เป๊ะๆ
สลับกับ OrderLine ที่เพิ่งยกระดับไปข้างบน: มันคือ entity ที่ “ถือ” id นั้นไว้อีกที ตัว OrderLine เองต้องเทียบกันด้วยค่าที่ Id ห่ออยู่เท่านั้น ไม่ใช่เทียบกันด้วยตัวมันเองทั้งก้อน — สองชั้นนี้ต่างกันโดยสิ้นเชิง: id เป็น value object ที่บรรจุอยู่ ข้างใน entity อีกที ไม่ใช่ตัว entity เอง และยิ่ง entity มีพฤติกรรม/สถานะที่เปลี่ยนได้ระหว่างอายุของมัน (OrderLine ในอนาคตอาจมี method เปลี่ยน Quantity ได้) ยิ่งต้องเทียบกันด้วย Id ล้วนๆ เพราะถ้ายังเทียบด้วยค่าทั้งก้อนแบบ record การเปลี่ยนแปลงแค่ field เดียวก็จะทำให้ Equals/GetHashCode เปลี่ยนตามไปด้วยทันที — entity ตัวเดิมจะ “หลุด” ออกจาก HashSet/Dictionary ใดๆ ที่เคยอ้างถึงมันอยู่ ทั้งที่มันยังเป็นสิ่งเดิมทุกประการในทางธุรกิจ
อ้างอิงข้าม aggregate ด้วย id เท่านั้น
หัวข้อที่มีชื่อว่า “อ้างอิงข้าม aggregate ด้วย id เท่านั้น”หลักการเดียวกันนี้ขยายไปไกลกว่าแค่ในตัว OrderLine เอง — ลองนึกภาพว่าวันหนึ่งเราต้องรู้ว่า Order เป็นของลูกค้าคนไหน แนวทางที่ผิดคือถือ object ของ Customer ไว้ใน Order ตรงๆ:
public sealed record CustomerId(Guid Value); // ใหม่ในบทนี้ — id ของ aggregate อื่น (Customer)
// ✅ อ้างอิงข้าม aggregate ด้วย id เท่านั้นpublic CustomerId CustomerId { get; }
// ❌ ห้ามถือ object ของ aggregate อื่นตรง ๆ — ผูก lifecycle/transaction ของ2 aggregate เข้าด้วยกันโดยไม่ตั้งใจ// public Customer Customer { get; }เหตุผลไม่ใช่แค่ “กันโหลดข้อมูลเกินจำเป็น” — แต่ละ aggregate (จะลงลึกเต็มๆ บทหน้า) มีขอบเขต transaction ของตัวเอง ถ้า Order ถือ Customer object ไว้ตรงๆ การแก้ Order กับการแก้ Customer จะถูกดึงเข้ามาอยู่ในขอบเขตความสอดคล้องเดียวกันโดยไม่ได้ตั้งใจ ทั้งที่ทั้งสองควรเป็นคนละหน่วยที่แยกจากกันได้อย่างอิสระ — การอ้างด้วย id แล้วให้ repository ของฝั่ง Customer โหลดข้อมูลเต็มเมื่อต้องใช้จริงเท่านั้น คือสิ่งที่รักษาขอบเขตนี้ไว้ หลักการเดียวกันนี้คือเหตุผลที่ OrderLine ก็ไม่ควรอ้างถึง Order ของตัวเองแบบ object ย้อนกลับเช่นกัน — ทิศทางการอ้างอิงไหลไปทางเดียวเสมอ จาก root ลงไปหา entity ย่อยของมัน ไม่ใช่ย้อนกลับ หรือข้ามออกไปนอก aggregate ตรงๆ
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้ยกระดับ OrderLine จาก record ที่ Course A ส่งมา ให้กลายเป็น entity ตัวจริงที่เทียบกันด้วย Id เท่านั้นผ่าน Entity<TId> — ไม่ใช่เพราะ record “ผิด” โดยตัวมันเอง แต่เพราะ semantics ของมัน (value equality บนทุก field) ไม่ตรงกับสิ่งที่ entity ต้องการ (identity equality บน Id เพียงตัวเดียว) ส่วน id ทั้งสามตัว (OrderId, OrderLineId, ProductId) ยังเป็น record เหมือนเดิม เพราะ id คือค่า ไม่ใช่สิ่งมีตัวตน — และหลักการเดียวกันนี้ก็ขยายไปถึงการอ้างอิงข้าม aggregate ด้วย id (CustomerId) แทนการถือ object ตรงๆ เสมอ
บทถัดไป (บทที่ 4) เราจะกลับไปมอง Order เอง — ไม่ใช่ในฐานะ entity เดี่ยวๆ แต่ในฐานะ aggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยความสอดคล้อง มีขอบเขตชัดเจน มี root เดียวเป็นประตูเข้า และเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLineTactical Design ที่คุม OrderLine ทุกบรรทัดไว้ และรักษา invariant ของทั้งกลุ่มผ่านประตูเดียว
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Entity — นิยามและคุณสมบัติของ entity โดยตรง
- Value Object — ตัวเทียบที่ทำให้เห็นเส้นแบ่งกับ entity ชัดขึ้น
- Entities (คอร์ส DDD Patterns บทที่ 16) — ฉบับเต็มของ entity, identity และการ persist
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3อะไรคือความแตกต่างหลักระหว่าง Entity กับ Value Object?