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

Entities — อ็อบเจ็กต์ที่​มี​ตัวตน

เมื่อ​คุณ​นั่ง​คุย​กับ domain expert พวก​เขา​มัก​พูด​ถึง​สิ่ง​ที่​มี “ตัวตน” ติดตัว​อยู่​ใน problem domain เช่น “ลูกค้า​คน​นี้” “รายการ​แข่งขัน​นัด​นั้น” หรือ “คำ​สั่ง​ซื้อ​หมายเลข​นี้” สิ่ง​เหล่า​นี้​คือ EntityEntityอ็อบเจ็กต์ที่​มี “ตัวตน” (identity) ต่อ​เนื่อง​ตาม​เวลา แม้​ค่า​ภายใน​เปลี่ยน ก็​ยัง​เป็น​สิ่ง​เดิม เช่น ลูกค้า, คำ​สั่ง​ซื้อ — แยกแยะ​ด้วย id ไม่ใช่​ด้วย​ค่าTactical Design และ​ต่าง​จาก Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design ตรง​ที่ — 2 entity ที่​มี “ค่า” เหมือน​กัน​ทุก​ประการ ก็​ยัง​ไม่​ถือว่า​เป็น​สิ่ง​เดียวกัน

บท​นี้​จะ​พา​คุณ​ดู​ว่า entity คือ​อะไร​ใน​เชิง​แนวคิด จะ “ค้น​พบ” มัน​ใน domain ได้​อย่างไร และ​เมื่อ​พบ​แล้ว​จะ implement มัน​ให้​ดี​อย่างไร — ตั้งแต่​การ​ให้ idIdentity“ตัวตน” ที่​ทำให้ Entity เป็น​สิ่ง​เดิม​ตลอด​อายุ แม้​ค่า​เปลี่ยน เช่น เลข​ที่​คำ​สั่ง​ซื้อ หรือ id ที่​ระบบ​สร้าง​ให้Tactical Design, การ​บังคับ InvariantInvariantกฎ​ทาง​ธุรกิจ​ที่ “ต้อง​เป็น​จริง​เสมอ” ภายใน Aggregate เช่น ยอด​รวม​คำ​สั่ง​ซื้อ​ต้อง​เท่ากับ​ผล​รวม​ของ​รายการ​สินค้า — ต้อง​ถูกต้อง​ภายใน transaction เดียวTactical Design, ไป​จนถึง​การ​ออกแบบ​ให้ entity “เน้น​พฤติกรรม” (behavior-rich) แทนที่​จะ​เป็น​เพียง​ถุง​ใส่​ข้อมูล

แก่น​ของ​บท​นี้

ใจความ​เดียว​ที่​ต้อง​จำ​ให้​ขึ้นใจ​คือ: entity คือ​แนวคิด​ที่​มี unique identity ใน problem domain รายละเอียด​การ implement (เลือก id แบบ​ใด, validate ตรง​ไหน) เปลี่ยนแปลง​ไป​ตาม​ยุค​และ​เทคโนโลยี​ได้​เสมอ แต่​หาก​คุณ​เข้าใจ “บทบาท​เชิง​แนวคิด” ของ entity อย่าง​ชัดเจน model ของ​คุณ​ก็​จะ​คง​รูปร่าง​ที่​ถูกต้อง​ไว้​ได้​เสมอ


วิธี​ที่​ดี​ที่สุด​ใน​การ​หา entity คือ ฟัง​วิธี​ที่ domain expert พูด สมมุติ​คุณ​คุย​กับ​ผู้เชี่ยวชาญ​ด้าน​การ​ท่องเที่ยว เขา​เล่า​ว่า​นักท่องเที่ยว​เลือก​โรงแรม​ที่​ชอบ​แล้ว​จอง​ที่พัก​ที่​นั่น และ​เน้น​ย้ำ​ว่า — เป็น​ไป​ไม่​ได้​เลย​ที่​นักท่องเที่ยว​จะ​ได้​ห้อง​พัก​ของ​โรงแรม “อื่น” แม้​โรงแรม​นั้น​จะ​ชื่อ​เหมือน​กัน​หรือ​มี​ค่าอื่นๆ เหมือน​กัน​ทุก​ประการ​ก็ตาม

นั่น​คือ​เขา​กำลัง​บอก​คุณ​เป็น​นัยว่า โรงแรม​เป็น entity เพราะ​ตัวตน (identity) และ​ความ​ไม่​ซ้ำ​ของ​มัน​คือ​สิ่ง​สำคัญ ถ้า​ไม่มี identity เรา​ก็​แยก​โรงแรม​หนึ่ง​ออก​จาก​อีก​โรงแรม​ไม่​ได้ และ​ลูกค้า​ก็​จะ​ไม่​ได้การ​จอง​อย่าง​ที่​เขา​คาด​หวัง

อีก​สัญญาณ​หนึ่ง​ที่​ใกล้​เคียง​กัน​คือ continuity (ความ​ต่อ​เนื่อง​ตาม​เวลา) เมื่อ domain expert พูด​ว่า “ออเดอร์​ถูกรับ​เข้า​ระบบ” → “ออเดอร์​ได้​รับ​การ​ยืนยัน​หลัง​ชำระ​เงิน” → “ออเดอร์​ถูก​ส่ง​โดย​พนักงาน​ส่ง” นี่​คือ​เบาะแส​ว่า​สิ่ง​นั้น​มี life cycle และ​การ​จะ​มี life cycle ได้ มัน​ต้อง​มี identity เพื่อ​ให้​ตามหา​และ​อัปเดต “ตัว​เดิม” ได้​ใน​ทุก​ช่วง​ของ​วงจร​ชีวิต

🛒 ฟัง​คำ​พูด​เพื่อ​หา entity

ใน​ระบบ e-commerce ประโยค​อย่าง “ออเดอร์ นี้​รอ​ชำระ​เงิน”, “ลูกค้า คน​นี้​เป็น​สมาชิก​ระดับ​ทอง”, “ตะกร้า ใบ​นี้​ถูก​ทิ้ง​ค้าง​ไว้​สาม​วัน” ล้วน​ชี้​ว่า Order, Customer, Basket เป็น entity — เพราะ​เรา​ต้องตา​มหา “ตัว​เดิม” กลับ​มา​แก้ไข​ใน​แต่ละ​ช่วง​เวลา ไม่ใช่​สนใจ​เพียง​ค่า​ของ​มัน

การ​ค้น​พบ entity เกิด​ขึ้น​ได้​ทุกเมื่อ ไม่​จำเป็น​ต้อง​นั่ง​ไล่​รายการ entity ทั้งหมด​ให้​ครบ​ตั้งแต่​แรก นัก DDD บาง​คน​เริ่ม​จาก​การ​มอง​หา event ที่​เกิด​ขึ้น​ใน domain ก่อน แล้ว​จึง​สืบ​ว่า entity ตัว​ใด​เกี่ยวข้อง​กับ​แต่ละ event — นี่​ก็​เป็น​อีก​เส้นทาง​หนึ่ง​ที่​ใช้ได้​ดี


การ​แยก entity ออก​จาก value object ทำได้​ยากกว่าที่​คิด เพราะ​มัน​ขึ้น​กับ context — แนวคิด​ที่​เป็น entity แน่ๆ ใน domain หนึ่ง อาจ​เป็น value object เต็ม​ตัว​ใน​อีก domain หนึ่ง

ตัวอย่าง​คลาสสิก​คือ เงิน ใน app ธนาคาร ลูกค้า​ฝาก​เงิน $100 พอ​ถอน​ออก​มา​ภายหลัง​อาจ​ได้​ธนบัตร​คนละ​ใบ​กับ​ที่​ฝาก​ไป แต่​ไม่มี​ใคร​สนใจ เพราะ​สิ่ง​ที่​สำคัญ​คือ “มูลค่า” ไม่ใช่​ตัวตน​ของ​ธนบัตร​แต่ละ​ใบ → เงิน​จึง​เป็น value object อย่าง​ชัดเจน แต่​ใน​อีก domain เช่น การ​ผลิต​หรือ​การ​ตรวจสอบ​ย้อน​กลับ (traceability) ของ​ธนบัตร ตัวตน​ของ​ธนบัตร​แต่ละ​ใบ (เลข​ซีเรียล) กลับ​สำคัญ​มาก → ธนบัตร​แต่ละ​ใบ​จึง​กลาย​เป็น entity

entity เน้น ‘ใคร’ value object เน้น ‘อะไร’

entity คือ​เรื่อง​ของ identity — “ใคร/ตัว​ไหน” ส่วน value object คือ​เรื่อง​ของ ค่า — “อะไร” คำถาม​ที่​ช่วย​ตัดสิน​ใจ​คือ: ถ้า​ค่า​ทุก​อย่าง​เท่า​กัน​หมด เรา​ยัง​จำเป็น​ต้อง​แยกแยะ​สอง​ตัว​นี้​ออก​จาก​กัน​หรือ​ไม่? ถ้า​ใช่ → entity, ถ้า​ไม่ → value object


เมื่อ​พบ entity แล้ว เรื่อง​พื้นฐาน​เรื่อง​แรก​ที่​ต้อง​ตัดสิน​ใจ​คือ id ของ​มัน​มา​จาก​ไหน มี​สาม​แนวทาง​หลัก

บาง​ครั้ง entity มี identifier ที่​ไม่​ซ้ำ​อยู่​แล้ว​ใน problem domain เรา​เรียก​มัน​ว่า Natural KeyNatural Keyidentifier ที่​มี​อยู่​แล้ว​ตาม​ธรรมชาติ​ใน problem domain ใช้​เป็น identity ของ entity ได้​เลย เช่น เลข​บัตร​ประชาชน ISBN เลข​ทะเบียน​นิติบุคคล เงื่อนไข​สำคัญ​คือ​มัน​ต้อง​ไม่มี​วัน​เปลี่ยนแปลง มิ​ฉะนั้น reference ที่​ชี้​ไป id เก่า​ทั่ว​ระบบ​จะ​พังTactical Design เช่น เลข​ประจำ​ตัว​ประชาชน, ISBN ของ​หนังสือ, เลข​ทะเบียน​นิติบุคคล หน้าที่​ของ​เรา​คือ​ร่วม​กับ domain expert เพื่อ “เปิดเผย” natural key เหล่า​นี้​ออก​มา

Book.cs
public class Book
{
public Book(ISBN isbn)
{
this.ISBN = isbn;
this.Id = isbn.Number; // ใช้ ISBN เป็น identity ตั้งแต่แรกเกิด
}
public string Id { get; private set; }
public ISBN ISBN { get; private set; }
}
natural key ต้อง​ไม่มี​วัน​เปลี่ยน

เงื่อนไข​สำคัญ​ของ natural key คือ​มัน ต้อง​ไม่​เปลี่ยนแปลง​โดย​เด็ดขาด ถ้า​มัน​เปลี่ยน คุณ​จะ​มี reference ทั่ว​ทั้ง​ระบบ​ที่​ยัง​ชี้​ไป​ยัง id เก่า กรณี​ดี​ที่สุด​คือ​ต้อง​ไล่​อัปเดต​ให้​ครบ​ทุก​จุด (ซึ่ง​น่า​เบื่อ) กรณี​แย่​ที่สุด​คือ​ลืม​อัปเดต​บาง​จุด​จน​เกิด​ปัญหา​ระดับ​ธุรกิจ และ​พึง​ระวัง​เรื่อง ORM ด้วย — บาง framework จะ “เขียน​ทับ” id ที่​คุณ​กำหนด​เอง เว้น​แต่​คุณ​จะ​ระบุ​ชัดเจน​ว่า​คุณ​เป็น​ผู้จัดการ id เอง

ถ้า domain ไม่มี identifier ตาม​ธรรมชาติ คุณ​ต้อง​สร้าง​เอง รูปแบบ​ที่​พบ​บ่อย​คือ:

  • เลข​รัน​นิ่ง (incremental) — กิน​พื้นที่​น้อย​ที่สุด แต่​ต้อง​ดูแล global counter ของ id ล่าสุด ถ้า app แครช​จน counter ที่​เก็บ​ใน static variable หาย​ไป id เก่า​อาจ​ถูก​นำ​กลับ​มา​ใช้​ซ้ำ การ persist counter ก็​มี​ค่า​ใช้​จ่าย​ด้าน​ประสิทธิภาพ​และ​ความ​ซับซ้อน (โดย​เฉพาะ​ใน​ระบบ​กระจาย/load-balanced ที่​ต้อง​ใช้ lock)
  • GUID/UUID — แก้​ปัญหา counter ได้​อย่าง​หมดจด Guid.NewGuid() รับประกัน​ความ​ไม่​ซ้ำ​เสมอ ไม่​ว่า​จะ​เรียก​จาก​เครื่อง​ใด​ใน​เวลา​เดียวกัน​ก็ตาม แลก​กับ​พื้นที่​จัด​เก็บ​ที่​มาก​ขึ้น​เล็กน้อย (ซึ่ง​ไม่มี​นัย​สำคัญ) สำหรับ​หลาย app GUID จึง​เป็น​ค่า​เริ่มต้น​ที่​ดี
  • string — เปิด​ช่อง​ให้​สร้าง​รูปแบบ id เอง​ได้ เช่น hash หรือ​การ​ผสม​หลาย attribute เข้า​ด้วย​กัน
VehicleFactory.cs
public static class VehicleFactory
{
public static Vehicle CreateVehicle()
{
var id = Guid.NewGuid(); // ไม่ซ้ำเสมอ ไม่ต้องมี global counter
return new Vehicle(id);
}
}
GUID ฝั่ง client กับ​หลาย backend

GUID มี​ประโยชน์​มาก​เมื่อ logic ใน​เบราว์เซอร์​ต้อง​สร้าง entity แล้ว​ยิง​ข้อมูล​ไป​ยัง​หลาย API หลัง​บ้าน​พร้อม​กัน — ถ้า​ไม่มี id ฝั่ง​หลัง​บ้าน​จะ​ไม่มี​ทาง​รู้​เลย​ว่า​ข้อมูล​แต่ละ​ก้อน​ที่​ส่ง​มา​หมาย​ถึง entity เดียวกัน วิธี​แก้​คือ สร้าง GUID ขึ้น​บน client ด้วย JavaScript แล้ว​แนบ​ไป​กับ​ทุก​คำขอ (เรื่อง​นี้​เคย​มี​การ​ถกเถียง​กัน​อย่าง​ยืดยาว​ใน​เมล​ลิง list ของ DDD/CQRS)

รูปที่ 16-1: client สร้าง GUID ขึ้นเองแล้วใช้เป็น EntityID แนบไปกับการ POST ข้อมูลไปยังหลาย Bounded Context — ทุกฝั่งจึงรู้ว่าเป็น entity เดียวกัน

บ่อย​ครั้ง​วิธี​ที่​ง่าย​และ​ปลอดภัย​เพียงพอ​คือ​มอบ​หน้าที่​สร้าง id ให้​ฐาน​ข้อมูล ฐาน​ข้อมูล​ส่วน​ใหญ่​ตั้งแต่ SQL Server ไป​จนถึง document database รองรับ​เรื่อง​นี้​อยู่​แล้ว รูปแบบ​ทั่วไป​คือ​สร้าง entity (ที่​ยัง​ไม่มี id) → ส่ง​ให้ data access library → เมื่อ transaction สำเร็จ entity ก็​จะ​มี id ติด​มา​เอง


นอกจาก identity แล้ว ข้อ​กำหนด​สำคัญ​อีก​อย่าง​ของ entity คือ​มัน​ต้อง self-validating และ valid อยู่​เสมอ คล้าย​กับ value object แต่​ของ entity จะ “ขึ้น​กับ context” มากกว่า เพราะ entity มี life cycle

FlightBooking.cs
public class FlightBooking
{
private bool confirmed = false;
public FlightBooking(Guid id, DateTime departureDate, Guid customerId)
{
if (id == null) throw new IdMissing();
if (departureDate == null) throw new DepartureDateMissing();
if (customerId == null) throw new CustomerIdMissing();
// ... เซ็ตค่า ...
}
public void Reschedule(DateTime newDeparture)
{
// กฎที่ขึ้นกับ context: ห้ามเลื่อนหลังยืนยันแล้ว
if (confirmed) throw new RescheduleRejected();
this.DepartureDate = newDeparture;
}
public void Confirm() => this.confirmed = true;
}

Reschedule() จะ​โยน exception ก็​ต่อ​เมื่อ booking ถูก​ยืนยัน​แล้ว​เท่านั้น — กฎ​นี้ ขึ้น​กับ context อาจ​ดูเหมือน​ขัด​กับ​คำ​ว่า “entity ต้อง valid เสมอ” แต่​แท้จริง​แล้ว​คำ​ว่า “always valid” ของ entity หมาย​ถึง “always contextually valid” ต่างหาก สังเกต​ด้วย​ว่า constructor ตรวจสอบ​ทุก argument — นี่​เป็น​แนวทาง​สำคัญ​ที่​กัน​ไม่​ให้ entity ตก​เข้า​สู่​สถานะ​ที่​ไม่​สอดคล้อง (inconsistent) แล้ว​ลุกลาม​ไป​ทั้ง domain

แล้ว invariant ล่ะ? invariant คือ “ความ​จริง​พื้นฐาน” ของ entity ที่ ต้อง​เป็น​จริง​เสมอ​ไม่​ว่า context ใด วิธี​หา​มัน​คือ​ถาม​ว่า “อะไร​ทำให้​โรงแรม​เป็น​โรงแรม?” — อาคาร​ที่​ไม่มี​ห้อง​เลย ก็​เป็น​โรงแรม​ไม่​ได้

Hotel.cs — EnforceInvariants
public class Hotel
{
private void EnforceInvariants(HotelRoomSummary rooms)
{
// invariant: โรงแรมต้องมีห้องอย่างน้อยหนึ่งห้อง — จริงเสมอทุก context
if (rooms.NumberOfSingleRooms < 1 &&
rooms.NumberOfDoubleRooms < 1 &&
rooms.NumberOfFamilyRooms < 1)
throw new HotelsMustHaveRooms();
}
}
validation ขึ้น​กับ context — invariant ไม่​ขึ้น

ทั้ง​สอง​หน้าตา​คล้าย​กัน แต่​ความ​ต่าง​ที่​ละเอียดอ่อน​และ​สำคัญ​คือ: invariant เป็น​จริง​เสมอ​ใน​ทุก​สถานการณ์ ส่วน contextual validation บังคับ​ใช้​เฉพาะ​บาง​สถานะ ของ life cycle การ​แยก​สอง​สิ่ง​นี้​ออก​จาก​กัน​ให้​ชัดเจน​คือ​ทักษะ​สำคัญ​ใน​การ​เข้าใจ domain ได้​อย่าง​ถูกต้อง


การ​ทำให้ entity โฟกัส​ที่ “หน้าที่​ดูแล identity” เป็น​เรื่อง​สำคัญ เพราะ​ช่วย​กัน entity ไม่​ให้​บวม​เทอะทะ​จาก​การ​ดึง​เอา​พฤติกรรม​ที่​เกี่ยวข้อง​ทั้งหมด​มา​รวม​ไว้​ที่​เดียว วิธีการ​คือ delegate พฤติกรรม​ที่​เกี่ยวข้อง​ออก​ไป​ยัง Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design และ Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design

ดู​ตัวอย่าง HolidayBooking ที่​ผลัก​กฎ “คืน​แรก​ต้อง​มา​ก่อน​คืน​สุดท้าย” และ “ต้อง​พัก​อย่าง​น้อย​สาม​คืน” ออก​ไป​ไว้​ใน value object ชื่อ Stay:

Stay.cs (value object)
public class Stay
{
public Stay(DateTime firstNight, DateTime lastNight)
{
if (firstNight > lastNight)
throw new FirstNightOfStayCannotBeAfterLastNight();
if (DoesNotMeetMinimumStayDuration(firstNight, lastNight))
throw new StayDoesNotMeetMinimumDuration();
// ... เซ็ตค่า ...
}
}

การ​ใส่ logic นี้​ลง​ใน HolidayBooking ตรงๆ ก็​ทำได้ แต่​จะ​ทำให้ entity ปน​กัน​ระหว่าง logic เรื่อง identity กับ logic เรื่อง “ช่วง​เข้า​พัก” ยิ่ง​หาก​มี​เรื่อง​อื่น​เพิ่ม​เข้า​มา เช่น รถ​รับ​ส่ง​หรือ​เที่ยว​บิน​เสริม การ​ยัด​ทุก​อย่าง​ไว้​ใน entity จะ​ทำให้ code บดบัง​กัน​และ​พัน​กัน​ยุ่งเหยิง — ทุก​ครั้ง​ที่​จะ​เพิ่ม​พฤติกรรม​ให้ entity ลอง​ถาม​ตัวเอง​ว่า ผลัก​มัน​ลง​ไป​ไว้​ใน value object ได้​หรือ​ไม่

Single Responsibility Principle (SRP)

หลีก​เลี่ยง​การ​ให้ class หนึ่ง​แบกรับ​ความ​รับผิดชอบ​หลาย​อย่าง เพราะ​ดูแล​รักษา​ยาก ทดสอบ​ยาก และ​มัก​กลาย​เป็น​ส่วน​ของ code ที่​ทุก​คน​เอือม​ระอา การ​ยึด SRP เป็น​คำ​แนะนำ​ที่​ยอดเยี่ยม​สำหรับ​ซอฟต์แวร์​โดย​ทั่วไป และ​ยิ่ง​สำคัญ​กับ entity — เมื่อ​ผลัก​พฤติกรรม​ลง​ไป​ไว้​ใน value object คุณ​ก็​สามารถ​แก้ไข​และ​ทดสอบ​ความ​รับผิดชอบ​เดียว​นั้น​ได้​โดย​ไม่​กระทบ​ส่วน​อื่น

ระวัง object graph ที่​ลึก​เกิน​ไป (Law of Demeter)

เมื่อ Stay และ property ของ​มัน​เปิด​เป็น public ทั้งหมด ผู้​ใช้ entity จะ​เรียก booking.Stay.FirstNight ได้ — การ​ไล่​ลึก​สาม​ชั้น​เช่น​นี้​ยัง​พอ​รับ​ได้ แต่​ยิ่ง​เปิด object graph ลึก​เท่าไร client ก็​ยิ่ง​ผูก​ตัวเอง​เข้า​กับ​โครงสร้าง​ภายใน​มาก​ขึ้น​เท่านั้น ทำให้ refactor ใน​ภายหลัง​ได้​ยาก นี่​คือ​สาระ​สำคัญ​ของ​หลัก Law of Demeter — ซึ่ง​ต้อง​ชั่ง​น้ำหนัก​เป็น​กรณี​ไป


นัก DDD จำนวน​มาก​เห็น​พ้อง​กัน​ว่า entity ควร เน้น​พฤติกรรม คือ interface ของ entity ควร​เปิดเผย method ที่​สื่อ​ความหมาย​ของ domain แทน​การ​เปิดเผย state ดิบๆ — แนวคิด​นี้​เกี่ยวพัน​ใกล้​ชิด​กับ​หลัก OOP ที่​ว่า Tell Don’t Ask

การ​ห่อ​หุ้ม state ไว้​ทำให้ state นั้น​ถูก​แก้ไข​ได้​โดย entity ที่​เป็น​เจ้าของ​เท่านั้น พฤติกรรม​ที่​ต้อง​แก้ state จึง​ต้อง​อยู่​ใน entity นั้น​ด้วย — เพื่อ​กัน​ไม่​ให้ logic ของ entity หลุด​ไป​อยู่​ผิด​ที่ ดังนั้น​จึง​ต้อง ระวัง​การ​เปิด getter และ​โดย​เฉพาะ setter เป็น public

ลอง​ดู SoccerCupMatch ที่​ติดตาม​ผล​ฟุตบอล​ถ้วย ถ้า​เรา​เปิด score เป็น public หมด:

SoccerCupMatch.cs — แบบ​เปิด state (ไม่​ดี)
public class SoccerCupMatch
{
public Scores Team1Scores { get; set; } // เปิดให้ใครก็แก้ได้
public Scores Team2Scores { get; set; }
}

ฟุตบอล​ถ้วย​มี​กฎ away goals — สอง​ทีม​เตะ​กัน​สอง​นัด โดย​แต่ละ​ทีม​ได้​เป็น​เจ้าบ้าน​หนึ่ง​นัด ถ้า​รวม​สกอร์แล้ว​เสมอ​กัน ทีม​ที่​ยิง​ประตู​ใน​สนาม​ของ​คู่แข่ง (away goals) ได้​มากกว่า​จะ​เป็น​ฝ่าย​ชนะ สมมุติ​ทีม A ชนะ 1–0 ที่​สนาม​ตัวเอง แล้ว​ทีม B ชนะ 2–1 ที่​สนาม​ตัวเอง รวม​เป็น 2–2 แต่​ทีม A ยิง away goal ได้ 1 ส่วน​ทีม B ได้ 0 → ทีม A ชนะ หาก client เห็น​เพียง​สกอร์ 2–2 มัน​อาจ​เข้าใจ​ผิด​ว่า​เสมอ และ​ที่​แย่​กว่า​นั้น​คือ client หลาย​ตัว​อาจ​คำนวณ​ผู้​ชนะ​คนละ​แบบ​จน​ได้​คำ​ตอบ​ไม่​ตรง​กัน

ทาง​แก้​คือ ห่อ​หุ้ม state ไว้ แล้ว​เปิด​เฉพาะ​พฤติกรรม:

SoccerCupMatch.cs — แบบ behavior-rich (ดี)
public class SoccerCupMatch
{
private Scores Team1Scores { get; set; } // private
private Scores Team2Scores { get; set; }
public Scores WinningTeamScores
{
get
{
// กฎทั้งหมด (away goals, จุดโทษ) อยู่ที่เดียว ไม่มีทางตีความผิด
// ...
}
}
}

แม้​คุณ​จะ​ไม่รู้จัก​ฟุตบอล​เลย เพียง​อ่าน code version นี้​ก็​อนุมาน​กฎ away goals ได้ และ​ที่​สำคัญ​ไม่​แพ้​กัน — เมื่อ state เป็น private และ​เปิด​เฉพาะ​พฤติกรรม client จะ “คุย​กับ” entity ด้วย​ภาษา​ของ domain เท่านั้น ไม่ใช่​เข้าไป​แก้ state ตรงๆ นี่​คือ​ก้าว​สำคัญ​สู่ rich domain model ที่​สื่อ​ความหมาย​ได้​ชัดเจน

กับดัก ‘จำลอง​โลก​จริง’ (Model the Real-World Fallacy)

คน​ที่​เพิ่ง​รู้จัก DDD มัก​หลง​เชื่อ​ว่า DDD คือ​การ “จำลอง​โลก​จริง” แล้ว​พยายาม​ใส่​พฤติกรรม​ใน​โลก​จริง​ของ entity ลง​ไป​ทั้งหมด ทั้ง​ที่​หลาย​อย่าง​ไม่​เกี่ยวข้อง​กับ app ที่​กำลัง​สร้าง​เลย ผล​คือ model บวม​ด้วย​แนวคิด​และ​ความ​ซับซ้อน​ที่​ไม่​ได้​ใช้งาน​จริง — domain model ไม่​ได้​มี​ไว้​เพื่อ​จำลอง domain แบบ​เต็ม​สเกล พฤติกรรม​ของ entity ถูก​กำหนด​โดย “ความ​ต้องการ​ของ app” เท่านั้น (ส่วน logic ที่มา​จาก UI ให้​ผลัก​ไป​ไว้​ที่ view model หรือ DTO แทน อย่า​ปล่อย​ให้ entity แปดเปื้อน)


ใน​ยุค​ที่ distributed system เป็น​เรื่อง​ปกติ คำ​แนะนำ​ที่​หนักแน่น​ของ​นัก DDD ส่วน​ใหญ่​คือ อย่า​กระจาย entity — entity หนึ่ง​ตัว​ควร​อยู่​ใน class เดียว ใน model เดียว ภายใน Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design เดียว

พิจารณา entity Customer ที่​อ้วน​เกิน​ไป ซึ่ง​แบก​ทั้ง​ที่​อยู่, ประวัติ​คำ​สั่ง​ซื้อ, รายละเอียด​การ​ชำระ​เงิน และ​ข้อมูล​สมาชิก​ไว้​ใน​ตัว​เดียว:

Customer.cs — เกิน 1 bounded context (มี​ปัญหา)
public class Customer
{
public AddressBook Addresses { get; private set; }
public Orders OrderHistory { get; private set; }
public PaymentDetails PaymentDetails { get; private set; }
public LoyaltySummary Loyalty { get; private set; }
}

ใน​ระบบ monolith อาจ​พอ​มี​เหตุผล แต่​ใน​ระบบ​กระจาย ข้อมูล​เหล่า​นี้​อาจ​อยู่​คนละ bounded context — การ​โหลด entity นี้​ใน​คราว​เดียว​อาจ​ต้อง query ข้าม​หลาย​ฐาน​ข้อมูล ก่อ​ให้​เกิด tight coupling ที่​กระทบ​ทั้ง scalability และ resiliency วิธี​ที่​ดี​กว่า​คือ แยก​ออก​เป็น​หลาย entity ตาม bounded context โดย​ให้​ทุก​ตัว​ถือ CustomerId ร่วม​กัน​เพื่อ​เชื่อม​ข้อมูล​กลับ​เข้าหา​กัน​ได้:

แยก​ตาม bounded context
namespace MarketingBoundedContext { public class Loyalty { /* CustomerId, LoyaltySummary */ } }
namespace AccountsBoundedContext { public class OrderHistory { /* CustomerId, Orders */ } }
namespace BillingBoundedContext { public class PaymentDetails { /* CustomerId, CardDetails */ } }
🛒 อย่า​ยัด Customer เป็น​ก้อน​เดียว

“ลูกค้า” ใน e-commerce ดูเหมือน​เป็น entity ก้อน​เดียว แต่​แท้จริง​แล้ว​มัน​คือ​หลาย​แนวคิด​ที่​อยู่​คนละ context — ฝ่าย​การ​ตลาด​สนใจ loyalty, ฝ่าย​บัญชี​สนใจ​ประวัติ​ออเดอร์, ฝ่าย​เรียก​เก็บ​เงิน​สนใจ​บัตร การ​แยก​มัน​ออก​แล้ว​ผูก​ด้วย CustomerId จะ align กับ problem domain ได้​ดี​กว่า​มาก แม้​ใน​ระบบ​ที่​ยัง​ไม่​กระจาย​ก็ตาม


ส่วน​นี้​คือ​ชุด​เครื่องมือ “ทาง​เลือก” ที่​ช่วย​ให้ entity สื่อ​ความหมาย​และ​ดูแล​รักษา​ง่าย​ขึ้น (ใช้​ต่อ​เมื่อ​พื้นฐาน​ข้าง​ต้น​ถูก​พิจารณา​แล้ว)

SpecificationSpecificationobject ที่​ห่อ​หุ้ม ‘กฎ​ทาง​ธุรกิจ’ ที่​บาง​ส่วน​ของ domain model ต้อง​ทำให้​เป็น​จริง ใช้​เป็น​เกณฑ์ query ได้​ด้วย หัวใจ​คือ method isSatisfiedBy(...) — เป็น​เครื่องมือ​ทำ logic ที่ implicit ให้ explicitTactical Design คือ class เล็กๆ ที่​มีหน้าที่​เดียว คล้าย policy ประโยชน์​คือ​สื่อ​ความหมาย​ชัดเจน (หนึ่ง​แนวคิด = 1 class) และ​ทดสอบ​ง่าย​เพราะ​เป็น immutable และ​ไร้ side effect แทนที่​จะ​ฝัง​กฎ “ห้าม​เลื่อน​เที่ยว​บิน​หลัง​ยืนยัน” ไว้​ใน entity เรา​ย้าย​มัน​ออก​มา:

Specification
public interface ISpecification<T>
{
bool IsSatisfiedBy(T entity);
}
public class NoDepartureReschedulingAfterBookingConfirmation
: ISpecification<FlightBooking>
{
public bool IsSatisfiedBy(FlightBooking booking) => !booking.Confirmed;
}

จุด​แข็ง​ที่แท้​จริง​ของ pattern นี้​คือ การ​ประกอบ (composition) เช่น หาก​สาย​การ​บิน​ยอม​ให้​สมาชิก frequent flyer เลื่อน​เที่ยว​บิน​ได้​แม้​ยืนยัน​แล้ว เรา​ก็​ใช้ OrSpecification ผูก​สอง​กฎ​เข้า​ด้วย​กัน — ลอง​กฎ frequent-flyer ก่อน ถ้า​ไม่​ผ่าน​จึง​ค่อย​ใช้​กฎ​ห้าม​เลื่อน โดย​ไม่​ต้อง​แก้ entity เลย

หลาย domain มี entity ที่​มี​หลาย​สถานะ​ใน life cycle เช่น ออเดอร์​อาหาร​ออนไลน์: “เข้า​คิว​ครัว” → “กำลัง​เตรียม” → “กำลัง​ปรุง” → “กำลัง​จัด​ส่ง” ออเดอร์​ที่​กำลัง​จัด​ส่ง​ย่อม​เอา​กลับ​เข้า​เตา​อบ​ไม่​ได้

หลาย​คน​อยาก​ใช้ state pattern เพื่อ​จัดการ life cycle นี้ แต่​นัก DDD จำนวน​มาก​ไม่​แนะนำ เพราะ state pattern สร้าง boilerplate มหาศาล​และ​เต็ม​ไป​ด้วย method ที่​ต้อง throw ActionNotPermittedInThisState() และ​ที่​สำคัญ​ยิ่ง​กว่า — มัน ไม่ explicit เพราะ​ยอม​ให้​เรียก method ที่​ไม่​ควร​เรียก​ได้​ตั้งแต่​แรก

ทาง​เลือก​ที่ DDD เชียร์​คือ สร้าง entity แยก1 class ต่อ​หนึ่ง​สถานะ โดย​ให้​แต่ละ class มี​เฉพาะ method ที่​ใช้ได้​จริง และ​คืน​ค่า​เป็น entity ของ​สถานะ​ถัด​ไป:

Explicit state modeling
public class InKitchenOnlineTakeawayOrder
{
// มีเฉพาะ method ที่ทำได้จริงในสถานะนี้
public InOvenOnlineTakeawayOrder Cook()
{
return new InOvenOnlineTakeawayOrder(this.Id, this.Address);
}
}

ผล​คือ boilerplate หาย​ไป และ type system บังคับ​กฎ​ของ domain ให้​เรา​เอง — ไม่มี entity ก้อน​เดียว​ที่​ลอย​ไป​ทั่ว model แล้ว​ถูก​เรียก method ที่​ไม่​ถูกต้อง​ได้​อีก​ต่อ​ไป

ถ้า​คุณ​เชื่อ​คำ​แนะนำ​เรื่อง “เลี่ยง getter/setter” คุณ​จะ​พบ​กับ​ปัญหา​คลาสสิก: แล้ว​จะ นำ​ข้อมูล​ออก​จาก entity ไป​แสดง​บน UI หรือ​ส่ง​อีเมล ได้​อย่างไร? memento pattern เสนอ​ทาง​เลือก​หนึ่ง — สร้าง “snapshot” ของ state ส่ง​ออก​ไป​ทาง​ประตู​หลัง โดยที่​โครงสร้าง​ภายใน​ของ entity ยัง​คง​เป็น private อยู่ ทำให้ refactor ภายใน​ได้​โดย​ไม่​กระทบ client ที่​ผูก​กับ snapshot

Basket.cs — memento
public class Basket
{
private int Id { get; set; }
private List<Item> Items { get; set; }
public BasketSnapshot GetSnapshot() // snapshot ที่ปลอดภัยให้ client ผูกได้
=> new BasketSnapshot(this.Id, this.Cost, this.Items.Count());
}

side effect ทำให้ code เข้าใจ​ยาก​และ​เป็น​แหล่ง​กำเนิด​ของ bug การ​เลี่ยง side effect เป็น​คำ​แนะนำ​ที่​ดี แต่​การ​เลี่ยง hidden side effect (side effect ที่​ซ่อน​อยู่) เป็น​สิ่ง​ที่​ต้อง​ทำ​เป็น​พื้นฐาน ดู Dice.Value() ที่​มีชื่อ​เหมือน query แต่​แท้จริง​แล้ว​เปลี่ยน​ค่า​ทุก​ครั้ง​ที่​ถูก​เรียก:

Dice — เทียบ​กัน
// ไม่ดี: ชื่อเหมือนอ่านค่า แต่แอบทอยใหม่ทุกครั้ง
public int Value() => r.Next(1, 7);
// ดี: Roll() ฟังดูเป็นคำสั่ง (มี side effect), Value เป็น property อ่านอย่างเดียว
public int Value { get; private set; }
public void Roll() => Value = r.Next(1, 7);

ตั้ง​ชื่อ​ให้​ตรง​กับ​เจตนา — method ที่​ฟัง​ดู​เป็น command (Roll) คือ​มี side effect ส่วน property/method ที่​ฟัง​ดู​เป็น query (Value) ต้อง​อ่าน​อย่าง​เดียว ไม่มี side effect ซ่อน​อยู่

🚢 entity ใน​ระบบ​ขนส่ง​สินค้า

ใน​ตัวอย่าง Cargo Shipping นั้น Cargo คือ entity อย่าง​ชัดเจน — มัน​มี Tracking IDTracking IDรหัส​ติดตาม​ที่​ไม่​ซ้ำ​ของ Cargo ใน​ตัวอย่าง​การ​ขนส่ง ใช้​เป็น identity ของ Cargo ตั้งแต่​ตอน​จอง​จน​ส่ง​มอบTactical Design เป็น identity และ​มี life cycle ตั้งแต่​จอง​จนถึง​ส่ง​มอบ เรา​จะ​ไม่​เปิด setter ให้​ใคร​เข้า​มา​แก้​สถานะ​ดิบๆ แต่​จะ​เปิด​เฉพาะ​พฤติกรรม​ที่​สื่อ domain เช่น AssignToRoute(...) และ​ผลัก​รายละเอียด​อย่าง Route SpecificationRoute SpecificationValue Object ที่​ระบุ “ความ​ต้องการ​ของ​ลูกค้า”: ต้นทาง ปลายทาง และ​กำหนด​เวลา​ถึง — มี method isSatisfiedBy() ตรวจ​ว่า​แผน​เดินทาง​ตรง​ตาม​นี้​หรือ​ไม่Tactical Design กับ ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design ออก​ไป​เป็น value object — entity จึง​ผอม​เพรียว โฟกัส​ที่ identity และ​กฎ​ของ​มัน​เอง


สรุป​ประเด็น​สำคัญ
  • entity = แนวคิด​ที่​มี unique identity ใน problem domain และ​มัก​มา​พร้อม life cycle
  • entity กับ value object ขึ้น​กับ context — สิ่ง​ที่​เป็น entity ใน domain หนึ่ง อาจ​เป็น value object ใน​อีก domain
  • การ​เลือก id (natural key / app-generated / datastore-generated) คือ​การ​ตัดสิน​ใจ​ขั้น​พื้นฐาน​ที่​เลี่ยง​ไม่​ได้
  • entity ต้อง valid เสมอ (ตาม context) และ​ต้อง​บังคับ invariant ที่​เป็น​จริง​เสมอ​ใน​ทุก context
  • ออกแบบ​ให้ เน้น​พฤติกรรม ไม่ใช่​ข้อมูล — ห่อ​หุ้ม state และ​ผลัก​พฤติกรรม​ลง​ไป​ไว้​ใน value object/domain service
  • อย่า​กระจาย entity ข้าม bounded context, ระวัง​กับดัก “จำลอง​โลก​จริง”, และ​เลี่ยง​ทั้ง state pattern กับ hidden side effect

🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

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

  • Entity — นิยาม​และ​คุณสมบัติ​ของ entity โดยตรง ต่อยอด​จาก​แก่น​ของ​บท​นี้​เรื่อง identity และ life cycle
  • Value Object — คู่​เทียบ​ของ entity ที่​บท​นี้​ใช้​แยกแยะ​ว่า​เมื่อไร​ควร​ยึด identity เมื่อไร​ควร​ยึด​ค่า
  • Specification — pattern ที่​บท​นี้​แนะนำ​ให้​แยก validation/business rule ออก​จาก entity ให้​เป็น class เล็กๆ ที่​ประกอบ​กัน​ได้

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

ข้อ 1 / 4

อะไรคือคุณสมบัติที่ทำให้แนวคิดหนึ่งเป็น ‘entity’ ตามหนังสือ?