ข้าม​ไป​ยัง​เนื้อหา

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 ตัวอย่าง

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);
❌ version ดิบ — OrderLine ที่​เทียบ​กัน​ด้วย​ค่า

ทวน 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 บังเอิญ​เท่า​กัน

ทาง​แก้​ไม่ใช่​การ​จำ​ไว้​เอง​ว่า “อย่า​ลืม 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

สังเกต​ว่า 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 ใดๆ ที่​เคย​อ้าง​ถึง​มัน​อยู่ ทั้ง​ที่​มัน​ยัง​เป็น​สิ่ง​เดิม​ทุก​ประการ​ใน​ทาง​ธุรกิจ

หลักการ​เดียวกัน​นี้​ขยาย​ไป​ไกล​กว่า​แค่​ใน​ตัว 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Entity — นิยาม​และ​คุณสมบัติ​ของ entity โดยตรง
  • Value Object — ตัว​เทียบ​ที่​ทำให้​เห็น​เส้น​แบ่ง​กับ entity ชัด​ขึ้น
  • Entities (คอร์ส DDD Patterns บท​ที่ 16) — ฉบับ​เต็ม​ของ entity, identity และ​การ persist

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

อะไรคือความแตกต่างหลักระหว่าง Entity กับ Value Object?