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 คือแนวคิดที่มี identity และ continuity
หัวข้อที่มีชื่อว่า “entity คือแนวคิดที่มี identity และ continuity”วิธีที่ดีที่สุดในการหา entity คือ ฟังวิธีที่ domain expert พูด สมมุติคุณคุยกับผู้เชี่ยวชาญด้านการท่องเที่ยว เขาเล่าว่านักท่องเที่ยวเลือกโรงแรมที่ชอบแล้วจองที่พักที่นั่น และเน้นย้ำว่า — เป็นไปไม่ได้เลยที่นักท่องเที่ยวจะได้ห้องพักของโรงแรม “อื่น” แม้โรงแรมนั้นจะชื่อเหมือนกันหรือมีค่าอื่นๆ เหมือนกันทุกประการก็ตาม
นั่นคือเขากำลังบอกคุณเป็นนัยว่า โรงแรมเป็น entity เพราะตัวตน (identity) และความไม่ซ้ำของมันคือสิ่งสำคัญ ถ้าไม่มี identity เราก็แยกโรงแรมหนึ่งออกจากอีกโรงแรมไม่ได้ และลูกค้าก็จะไม่ได้การจองอย่างที่เขาคาดหวัง
อีกสัญญาณหนึ่งที่ใกล้เคียงกันคือ continuity (ความต่อเนื่องตามเวลา) เมื่อ domain expert พูดว่า “ออเดอร์ถูกรับเข้าระบบ” → “ออเดอร์ได้รับการยืนยันหลังชำระเงิน” → “ออเดอร์ถูกส่งโดยพนักงานส่ง” นี่คือเบาะแสว่าสิ่งนั้นมี life cycle และการจะมี life cycle ได้ มันต้องมี identity เพื่อให้ตามหาและอัปเดต “ตัวเดิม” ได้ในทุกช่วงของวงจรชีวิต
ในระบบ e-commerce ประโยคอย่าง “ออเดอร์ นี้รอชำระเงิน”, “ลูกค้า คนนี้เป็นสมาชิกระดับทอง”, “ตะกร้า ใบนี้ถูกทิ้งค้างไว้สามวัน” ล้วนชี้ว่า Order, Customer, Basket เป็น entity — เพราะเราต้องตามหา “ตัวเดิม” กลับมาแก้ไขในแต่ละช่วงเวลา ไม่ใช่สนใจเพียงค่าของมัน
การค้นพบ entity เกิดขึ้นได้ทุกเมื่อ ไม่จำเป็นต้องนั่งไล่รายการ entity ทั้งหมดให้ครบตั้งแต่แรก นัก DDD บางคนเริ่มจากการมองหา event ที่เกิดขึ้นใน domain ก่อน แล้วจึงสืบว่า entity ตัวใดเกี่ยวข้องกับแต่ละ event — นี่ก็เป็นอีกเส้นทางหนึ่งที่ใช้ได้ดี
entity หรือ value object ขึ้นกับ context
หัวข้อที่มีชื่อว่า “entity หรือ value object ขึ้นกับ context”การแยก entity ออกจาก value object ทำได้ยากกว่าที่คิด เพราะมันขึ้นกับ context — แนวคิดที่เป็น entity แน่ๆ ใน domain หนึ่ง อาจเป็น value object เต็มตัวในอีก domain หนึ่ง
ตัวอย่างคลาสสิกคือ เงิน ใน app ธนาคาร ลูกค้าฝากเงิน $100 พอถอนออกมาภายหลังอาจได้ธนบัตรคนละใบกับที่ฝากไป แต่ไม่มีใครสนใจ เพราะสิ่งที่สำคัญคือ “มูลค่า” ไม่ใช่ตัวตนของธนบัตรแต่ละใบ → เงินจึงเป็น value object อย่างชัดเจน แต่ในอีก domain เช่น การผลิตหรือการตรวจสอบย้อนกลับ (traceability) ของธนบัตร ตัวตนของธนบัตรแต่ละใบ (เลขซีเรียล) กลับสำคัญมาก → ธนบัตรแต่ละใบจึงกลายเป็น entity
entity คือเรื่องของ identity — “ใคร/ตัวไหน” ส่วน value object คือเรื่องของ ค่า — “อะไร” คำถามที่ช่วยตัดสินใจคือ: ถ้าค่าทุกอย่างเท่ากันหมด เรายังจำเป็นต้องแยกแยะสองตัวนี้ออกจากกันหรือไม่? ถ้าใช่ → entity, ถ้าไม่ → value object
การกำหนด identifier ให้ entity
หัวข้อที่มีชื่อว่า “การกำหนด identifier ให้ entity”เมื่อพบ entity แล้ว เรื่องพื้นฐานเรื่องแรกที่ต้องตัดสินใจคือ id ของมันมาจากไหน มีสามแนวทางหลัก
1. Natural Key — กุญแจที่มีอยู่แล้วใน domain
หัวข้อที่มีชื่อว่า “1. Natural Key — กุญแจที่มีอยู่แล้วใน domain”บางครั้ง entity มี identifier ที่ไม่ซ้ำอยู่แล้วใน problem domain เราเรียกมันว่า Natural KeyNatural Keyidentifier ที่มีอยู่แล้วตามธรรมชาติใน problem domain ใช้เป็น identity ของ entity ได้เลย เช่น เลขบัตรประชาชน ISBN เลขทะเบียนนิติบุคคล เงื่อนไขสำคัญคือมันต้องไม่มีวันเปลี่ยนแปลง มิฉะนั้น reference ที่ชี้ไป id เก่าทั่วระบบจะพังTactical Design เช่น เลขประจำตัวประชาชน, ISBN ของหนังสือ, เลขทะเบียนนิติบุคคล หน้าที่ของเราคือร่วมกับ domain expert เพื่อ “เปิดเผย” natural key เหล่านี้ออกมา
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 คือมัน ต้องไม่เปลี่ยนแปลงโดยเด็ดขาด ถ้ามันเปลี่ยน คุณจะมี reference ทั่วทั้งระบบที่ยังชี้ไปยัง id เก่า กรณีดีที่สุดคือต้องไล่อัปเดตให้ครบทุกจุด (ซึ่งน่าเบื่อ) กรณีแย่ที่สุดคือลืมอัปเดตบางจุดจนเกิดปัญหาระดับธุรกิจ และพึงระวังเรื่อง ORM ด้วย — บาง framework จะ “เขียนทับ” id ที่คุณกำหนดเอง เว้นแต่คุณจะระบุชัดเจนว่าคุณเป็นผู้จัดการ id เอง
2. id ที่ app สร้างเอง
หัวข้อที่มีชื่อว่า “2. id ที่ app สร้างเอง”ถ้า 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 เข้าด้วยกัน
public static class VehicleFactory{ public static Vehicle CreateVehicle() { var id = Guid.NewGuid(); // ไม่ซ้ำเสมอ ไม่ต้องมี global counter return new Vehicle(id); }}GUID มีประโยชน์มากเมื่อ logic ในเบราว์เซอร์ต้องสร้าง entity แล้วยิงข้อมูลไปยังหลาย API หลังบ้านพร้อมกัน — ถ้าไม่มี id ฝั่งหลังบ้านจะไม่มีทางรู้เลยว่าข้อมูลแต่ละก้อนที่ส่งมาหมายถึง entity เดียวกัน วิธีแก้คือ สร้าง GUID ขึ้นบน client ด้วย JavaScript แล้วแนบไปกับทุกคำขอ (เรื่องนี้เคยมีการถกเถียงกันอย่างยืดยาวในเมลลิง list ของ DDD/CQRS)

3. ให้ datastore สร้าง id ให้
หัวข้อที่มีชื่อว่า “3. ให้ datastore สร้าง id ให้”บ่อยครั้งวิธีที่ง่ายและปลอดภัยเพียงพอคือมอบหน้าที่สร้าง id ให้ฐานข้อมูล ฐานข้อมูลส่วนใหญ่ตั้งแต่ SQL Server ไปจนถึง document database รองรับเรื่องนี้อยู่แล้ว รูปแบบทั่วไปคือสร้าง entity (ที่ยังไม่มี id) → ส่งให้ data access library → เมื่อ transaction สำเร็จ entity ก็จะมี id ติดมาเอง
validate และบังคับ invariant
หัวข้อที่มีชื่อว่า “validate และบังคับ invariant”นอกจาก identity แล้ว ข้อกำหนดสำคัญอีกอย่างของ entity คือมันต้อง self-validating และ valid อยู่เสมอ คล้ายกับ value object แต่ของ entity จะ “ขึ้นกับ context” มากกว่า เพราะ entity มี life cycle
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 ใด วิธีหามันคือถามว่า “อะไรทำให้โรงแรมเป็นโรงแรม?” — อาคารที่ไม่มีห้องเลย ก็เป็นโรงแรมไม่ได้
public class Hotel{ private void EnforceInvariants(HotelRoomSummary rooms) { // invariant: โรงแรมต้องมีห้องอย่างน้อยหนึ่งห้อง — จริงเสมอทุก context if (rooms.NumberOfSingleRooms < 1 && rooms.NumberOfDoubleRooms < 1 && rooms.NumberOfFamilyRooms < 1) throw new HotelsMustHaveRooms(); }}ทั้งสองหน้าตาคล้ายกัน แต่ความต่างที่ละเอียดอ่อนและสำคัญคือ: invariant เป็นจริงเสมอในทุกสถานการณ์ ส่วน contextual validation บังคับใช้เฉพาะบางสถานะ ของ life cycle การแยกสองสิ่งนี้ออกจากกันให้ชัดเจนคือทักษะสำคัญในการเข้าใจ domain ได้อย่างถูกต้อง
ผลักพฤติกรรมออกไปยัง value object และ domain service
หัวข้อที่มีชื่อว่า “ผลักพฤติกรรมออกไปยัง value object และ domain service”การทำให้ entity โฟกัสที่ “หน้าที่ดูแล identity” เป็นเรื่องสำคัญ เพราะช่วยกัน entity ไม่ให้บวมเทอะทะจากการดึงเอาพฤติกรรมที่เกี่ยวข้องทั้งหมดมารวมไว้ที่เดียว วิธีการคือ delegate พฤติกรรมที่เกี่ยวข้องออกไปยัง Value ObjectValue Objectอ็อบเจ็กต์ที่สำคัญที่ “ค่า” ของมัน ไม่มีตัวตนเฉพาะ สองตัวที่ค่าเท่ากันถือว่าเท่ากัน ควรเป็น immutable (เปลี่ยนค่าไม่ได้) เช่น เงิน, วันที่, พิกัดTactical Design และ Domain ServiceDomain Serviceบริการที่ถือ “ตรรกะธุรกิจ” ซึ่งไม่เข้ากับ Entity หรือ Value Object ตัวใดตัวหนึ่งโดยธรรมชาติ เช่น การโอนเงินที่เกี่ยวข้องกับสองบัญชี — ไร้สถานะ (stateless)Tactical Design
ดูตัวอย่าง HolidayBooking ที่ผลักกฎ “คืนแรกต้องมาก่อนคืนสุดท้าย” และ “ต้องพักอย่างน้อยสามคืน” ออกไปไว้ใน value object ชื่อ Stay:
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 ได้หรือไม่
หลีกเลี่ยงการให้ class หนึ่งแบกรับความรับผิดชอบหลายอย่าง เพราะดูแลรักษายาก ทดสอบยาก และมักกลายเป็นส่วนของ code ที่ทุกคนเอือมระอา การยึด SRP เป็นคำแนะนำที่ยอดเยี่ยมสำหรับซอฟต์แวร์โดยทั่วไป และยิ่งสำคัญกับ entity — เมื่อผลักพฤติกรรมลงไปไว้ใน value object คุณก็สามารถแก้ไขและทดสอบความรับผิดชอบเดียวนั้นได้โดยไม่กระทบส่วนอื่น
เมื่อ 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 หมด:
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 ไว้ แล้วเปิดเฉพาะพฤติกรรม:
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 ที่สื่อความหมายได้ชัดเจน
คนที่เพิ่งรู้จัก DDD มักหลงเชื่อว่า DDD คือการ “จำลองโลกจริง” แล้วพยายามใส่พฤติกรรมในโลกจริงของ entity ลงไปทั้งหมด ทั้งที่หลายอย่างไม่เกี่ยวข้องกับ app ที่กำลังสร้างเลย ผลคือ model บวมด้วยแนวคิดและความซับซ้อนที่ไม่ได้ใช้งานจริง — domain model ไม่ได้มีไว้เพื่อจำลอง domain แบบเต็มสเกล พฤติกรรมของ entity ถูกกำหนดโดย “ความต้องการของ app” เท่านั้น (ส่วน logic ที่มาจาก UI ให้ผลักไปไว้ที่ view model หรือ DTO แทน อย่าปล่อยให้ entity แปดเปื้อน)
ออกแบบเผื่อระบบกระจาย (distribution)
หัวข้อที่มีชื่อว่า “ออกแบบเผื่อระบบกระจาย (distribution)”ในยุคที่ distributed system เป็นเรื่องปกติ คำแนะนำที่หนักแน่นของนัก DDD ส่วนใหญ่คือ อย่ากระจาย entity — entity หนึ่งตัวควรอยู่ใน class เดียว ใน model เดียว ภายใน Bounded ContextBounded Contextขอบเขตที่ชัดเจน (มักเป็นระบบย่อยหรือทีมหนึ่ง) ที่ model หนึ่งใช้ได้และมีความหมายแน่นอน ภายในขอบเขตนี้ Ubiquitous Language สอดคล้องกัน 100%Strategic Design เดียว
พิจารณา entity Customer ที่อ้วนเกินไป ซึ่งแบกทั้งที่อยู่, ประวัติคำสั่งซื้อ, รายละเอียดการชำระเงิน และข้อมูลสมาชิกไว้ในตัวเดียว:
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 ร่วมกันเพื่อเชื่อมข้อมูลกลับเข้าหากันได้:
namespace MarketingBoundedContext { public class Loyalty { /* CustomerId, LoyaltySummary */ } }namespace AccountsBoundedContext { public class OrderHistory { /* CustomerId, Orders */ } }namespace BillingBoundedContext { public class PaymentDetails { /* CustomerId, CardDetails */ } }“ลูกค้า” ใน e-commerce ดูเหมือนเป็น entity ก้อนเดียว แต่แท้จริงแล้วมันคือหลายแนวคิดที่อยู่คนละ context — ฝ่ายการตลาดสนใจ loyalty, ฝ่ายบัญชีสนใจประวัติออเดอร์, ฝ่ายเรียกเก็บเงินสนใจบัตร การแยกมันออกแล้วผูกด้วย CustomerId จะ align กับ problem domain ได้ดีกว่ามาก แม้ในระบบที่ยังไม่กระจายก็ตาม
หลักการและ pattern เสริมสำหรับ entity
หัวข้อที่มีชื่อว่า “หลักการและ pattern เสริมสำหรับ entity”ส่วนนี้คือชุดเครื่องมือ “ทางเลือก” ที่ช่วยให้ entity สื่อความหมายและดูแลรักษาง่ายขึ้น (ใช้ต่อเมื่อพื้นฐานข้างต้นถูกพิจารณาแล้ว)
Specification — แยก validation ออกเป็น class เล็กๆ
หัวข้อที่มีชื่อว่า “Specification — แยก validation ออกเป็น class เล็กๆ”SpecificationSpecificationobject ที่ห่อหุ้ม ‘กฎทางธุรกิจ’ ที่บางส่วนของ domain model ต้องทำให้เป็นจริง ใช้เป็นเกณฑ์ query ได้ด้วย หัวใจคือ method isSatisfiedBy(...) — เป็นเครื่องมือทำ logic ที่ implicit ให้ explicitTactical Design คือ class เล็กๆ ที่มีหน้าที่เดียว คล้าย policy ประโยชน์คือสื่อความหมายชัดเจน (หนึ่งแนวคิด = 1 class) และทดสอบง่ายเพราะเป็น immutable และไร้ side effect แทนที่จะฝังกฎ “ห้ามเลื่อนเที่ยวบินหลังยืนยัน” ไว้ใน entity เราย้ายมันออกมา:
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 เลย
เลี่ยง State Pattern — สร้าง class แยกต่อสถานะแทน
หัวข้อที่มีชื่อว่า “เลี่ยง State Pattern — สร้าง class แยกต่อสถานะแทน”หลาย domain มี entity ที่มีหลายสถานะใน life cycle เช่น ออเดอร์อาหารออนไลน์: “เข้าคิวครัว” → “กำลังเตรียม” → “กำลังปรุง” → “กำลังจัดส่ง” ออเดอร์ที่กำลังจัดส่งย่อมเอากลับเข้าเตาอบไม่ได้
หลายคนอยากใช้ state pattern เพื่อจัดการ life cycle นี้ แต่นัก DDD จำนวนมากไม่แนะนำ เพราะ state pattern สร้าง boilerplate มหาศาลและเต็มไปด้วย method ที่ต้อง throw ActionNotPermittedInThisState() และที่สำคัญยิ่งกว่า — มัน ไม่ explicit เพราะยอมให้เรียก method ที่ไม่ควรเรียกได้ตั้งแต่แรก
ทางเลือกที่ DDD เชียร์คือ สร้าง entity แยก1 class ต่อหนึ่งสถานะ โดยให้แต่ละ class มีเฉพาะ method ที่ใช้ได้จริง และคืนค่าเป็น entity ของสถานะถัดไป:
public class InKitchenOnlineTakeawayOrder{ // มีเฉพาะ method ที่ทำได้จริงในสถานะนี้ public InOvenOnlineTakeawayOrder Cook() { return new InOvenOnlineTakeawayOrder(this.Id, this.Address); }}ผลคือ boilerplate หายไป และ type system บังคับกฎของ domain ให้เราเอง — ไม่มี entity ก้อนเดียวที่ลอยไปทั่ว model แล้วถูกเรียก method ที่ไม่ถูกต้องได้อีกต่อไป
Memento Pattern — ดึงข้อมูลออกโดยไม่เปิด getter/setter
หัวข้อที่มีชื่อว่า “Memento Pattern — ดึงข้อมูลออกโดยไม่เปิด getter/setter”ถ้าคุณเชื่อคำแนะนำเรื่อง “เลี่ยง getter/setter” คุณจะพบกับปัญหาคลาสสิก: แล้วจะ นำข้อมูลออกจาก entity ไปแสดงบน UI หรือส่งอีเมล ได้อย่างไร? memento pattern เสนอทางเลือกหนึ่ง — สร้าง “snapshot” ของ state ส่งออกไปทางประตูหลัง โดยที่โครงสร้างภายในของ entity ยังคงเป็น private อยู่ ทำให้ refactor ภายในได้โดยไม่กระทบ client ที่ผูกกับ snapshot
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());}เลี่ยง hidden side effect
หัวข้อที่มีชื่อว่า “เลี่ยง hidden side effect”side effect ทำให้ code เข้าใจยากและเป็นแหล่งกำเนิดของ bug การเลี่ยง side effect เป็นคำแนะนำที่ดี แต่การเลี่ยง hidden side effect (side effect ที่ซ่อนอยู่) เป็นสิ่งที่ต้องทำเป็นพื้นฐาน ดู Dice.Value() ที่มีชื่อเหมือน query แต่แท้จริงแล้วเปลี่ยนค่าทุกครั้งที่ถูกเรียก:
// ไม่ดี: ชื่อเหมือนอ่านค่า แต่แอบทอยใหม่ทุกครั้ง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 ซ่อนอยู่
ในตัวอย่าง 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:
- Entity — นิยามและคุณสมบัติของ entity โดยตรง ต่อยอดจากแก่นของบทนี้เรื่อง identity และ life cycle
- Value Object — คู่เทียบของ entity ที่บทนี้ใช้แยกแยะว่าเมื่อไรควรยึด identity เมื่อไรควรยึดค่า
- Specification — pattern ที่บทนี้แนะนำให้แยก validation/business rule ออกจาก entity ให้เป็น class เล็กๆ ที่ประกอบกันได้
เช็กความเข้าใจ — บทที่ 16
ข้อ 1 / 4อะไรคือคุณสมบัติที่ทำให้แนวคิดหนึ่งเป็น ‘entity’ ตามหนังสือ?