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

Aggregates — ขอบเขต​ของ​ความ​ถูกต้อง

โครงสร้าง​ของ domain modelDomain ModelModel ของ Domain ที่​ฝัง​กฎ​และ​กระบวนการ​ทาง​ธุรกิจ​ไว้​ใน code จริง หัวใจ​ของ DDD คือ​การ​พัฒนา domain model ที่ “เข้าใจ​ธุรกิจ​อย่าง​ลึกซึ้ง”Strategic Design ประกอบ​ด้วย EntityEntityอ็อบเจ็กต์ที่​มี “ตัวตน” (identity) ต่อ​เนื่อง​ตาม​เวลา แม้​ค่า​ภายใน​เปลี่ยน ก็​ยัง​เป็น​สิ่ง​เดิม เช่น ลูกค้า, คำ​สั่ง​ซื้อ — แยกแยะ​ด้วย id ไม่ใช่​ด้วย​ค่าTactical Design และ Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design ที่​แทน​แนวคิด​ต่างๆ ใน problem domain แต่​สิ่ง​ที่​ทำให้ model ซับซ้อน​จน​สับสน​เวลา implement จริง ไม่ใช่​ตัว object เอง — มัน​คือ จำนวน​และ​ชนิด​ของ​ความ​สัมพันธ์ (relationship) ระหว่าง object เหล่า​นั้น​ต่างหาก

บท​นี้​พูด​ถึง​สอง​เรื่อง​ที่​เกี่ยวพัน​กัน: (1) วิธี​ทำให้​ความ​สัมพันธ์​ระหว่าง domain object เรียบ​ง่าย​และ​สอดคล้อง​กับ domain invariantInvariantกฎ​ทาง​ธุรกิจ​ที่ “ต้อง​เป็น​จริง​เสมอ” ภายใน Aggregate เช่น ยอด​รวม​คำ​สั่ง​ซื้อ​ต้อง​เท่ากับ​ผล​รวม​ของ​รายการ​สินค้า — ต้อง​ถูกต้อง​ภายใน transaction เดียวTactical Design และ (2) แนวคิด​หลัก​ของ​บท — AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ซึ่ง​เป็น consistency boundary ที่​ตัด model ก้อน​ใหญ่​ออก​เป็นกลุ่ม (cluster) เล็กๆ ที่​จัดการ​ได้​ง่าย​ขึ้น Aggregate คือ tactical pattern ที่​ทรง​พลัง​ที่สุด​ตัว​หนึ่ง แต่​ก็​เป็น​หนึ่ง​ใน​ตัว​ที่​ออกแบบ​ให้​ถูกต้อง​ได้​ยาก​ที่สุด​เช่น​กัน


มือใหม่ DDD มัก​โฟกัส​ที่ entity และ value object แต่​ไม่​ใส่ใจความ​สัมพันธ์​ระหว่าง​มัน ผล​คือ​เผลอ​สร้าง​ความ​สัมพันธ์​ที่ “เลียน​แบบ​ชีวิต​จริง” หรือ​แย่​กว่า​นั้น​คือ “เลียน​แบบ data model” โดย​ไม่​ถาม​ว่า​ความ​สัมพันธ์​นั้น ทำงาน​อะไร​ให้ invariant หรือ​เปล่า model แบบ​นี้​บดบัง​แนวคิด​สำคัญ ทำให้​ทั้ง domain expert และ​นัก​พัฒนา​สับสน และ​นำ​ไป​สร้าง​จริง​ได้​ยาก

หัวใจ​ของ​หัวข้อ​นี้

domain model ไม่ใช่​สิ่ง​เดียว​กับ data model จุด​ประสงค์​ของ domain model คือ รองรับ invariant และ use case ไม่ใช่​รองรับหน้า​จอ UI หรือ​การ​สร้าง​รายงาน — ความ​สัมพันธ์​ใด​ที่​ไม่​ได้​ทำงาน​ให้ invariant ก็​ไม่​ต้อง implement มัน อย่า​จำลอง​ชีวิต​จริง

model ที่​สร้าง​เพื่อ​สะท้อน​ความ​จริง​มัก​เต็ม​ไป​ด้วย​ความ​สัมพันธ์​แบบ สอง​ทาง (bidirectional) คือ object สอง​ตัว​อ้าง​ถึงกัน​และ​กัน ดู​ตัวอย่าง​ระบบ​จัด​ซื้อ​ใน​รูป​ที่ 19-1: เรา​เดิน​จาก Supplier ไป PurchaseOrder ได้​และ​เดิน​กลับ​ได้, เดิน​จาก Supplier ไป Product ได้​และ​กลับ​ได้ — ORM ทำให้​สร้าง​ความ​สัมพันธ์​แบบ​นี้​ง่าย​เกิน​ไป จน​เกิด object graph ที่​ลึก​และ performance ตก

รูปที่ 19-1: ความสัมพันธ์แบบสองทางระหว่าง Supplier, PurchaseOrder และ Product ที่ก่อให้เกิดความซับซ้อน — ลูกศรวิ่งสวนกันทุกทิศ

จำเป็น​ไหม​ที่​ต้อง​โหลด Product ทั้งหมด​ของ Supplier ทั้ง​ที่​เรา​แค่​อยาก​แก้​เบอร์​ติดต่อ​ของ supplier? วิธี​ลด​ความ​ซับซ้อน​คือ บีบ​ให้​ความ​สัมพันธ์​เดินทาง​ได้​ทิศ​เดียว — เปลี่ยน​จาก bidirectional ให้​เป็น unidirectional เวลา​ออกแบบ ให้​ถาม​เสมอ​ว่า: ความ​สัมพันธ์​นี้​รองรับ พฤติกรรม​ทาง​ธุรกิจ (business behavior) อะไร และ ใคร ต้อง​ใช้​มัน

ถ้า​ความ​สัมพันธ์​เป็น​แบบ one/many-to-many ให้ qualify มัน​เพื่อ​จำกัด​จำนวน object ที่​ต้อง​โหลด เช่น สัญญา​มือถือ​หนึ่ง​ฉบับ (Contract) ที่​เก็บ​สาย​โทร (Calls) ทั้งหมด ลูกค้า​ที่​อยู่​เดือน​ที่ 23 ของ​สัญญา 24 เดือน​จะ​มี​ข้อมูล​สาย​เป็น​ร้อยๆ รายการ — แต่ invariant เรื่อง​นาที​ฟรี​คง​เหลือ​ต้องการ​แค่ “สาย​ของ​รอบ​บิล​ปัจจุบัน” เท่านั้น จึง​โหลด​เฉพาะ​รอบ​นั้น​ก็​พอ (ทำ​ที่​ระดับ repository / database)

แนวทาง​ที่​ทรง​พลัง​ที่สุด​คือ เก็บ ID แทน object reference แล้ว​ใช้ RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design ใน Application ServiceApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design ไป​ดึง object ที่ use case ต้องการ ลอง​ดู​เคส​คลาสสิก: ใน​ชีวิต​จริง​ลูกค้า​หนึ่ง​คน​มี​หลาย​คำ​สั่ง​ซื้อ แต่​ใน​พื้นที่​คำ​ตอบ (solution space) ของ app ไม่มี invariant ใด ที่​บังคับ​ให้ object Customer ต้อง​ถือ collection ของ Order ทั้งหมด — การ​สร้าง​ความ​สัมพันธ์​นี้​เป็น object reference จึง​เพิ่ม​ความ​ซับซ้อน​โดย​ไม่​จำเป็น

OrderApplicationService.cs (เก็บ ID + repository)
public void PlaceOrder(Guid customerId, IEnumerable<Product> products)
{
// ประสานงานใน application service: ดึงเฉพาะสิ่งที่ use case นี้ต้องการ
var customer = _customerRepository.FindBy(customerId);
var addressBook = _addressBookRepository.FindBy(customerId);
var order = customer.CreateOrderFor(products);
order.DispatchTo(addressBook.Default); // ใช้ค่าเริ่มต้น ไม่ต้องโหลด order เก่าทั้งหมด
_orderRepository.Add(order);
}

รูปที่ 19-6: model ที่เรียบง่ายขึ้นเมื่อใช้ Customer ID แทน object reference — Order ถือแค่ Customer ID ไม่ต้องชี้ไปยัง object โดยตรง

วิธี​ดู​ว่า object reference จำเป็น​ไหม ให้​ถาม: ความ​สัมพันธ์​นี้​รองรับ invariant ของ use case หรือ​เปล่า? Order ต้องการ​แค่ Customer ID เพื่อ​ทำ​ตาม invariant ไม่​ต้อง​มี reference ที่​เดิน​ไป​หา object ได้ — กรณีอื่นๆ ทั้งหมด ให้​เลือก ID + repository เพื่อ​ลด coupling


การ​ลด​และ​จำกัด​ความ​สัมพันธ์​ช่วย​ให้ model เรียบ​ง่าย​ขึ้น แต่​ยัง​เหลือ​อีก​หนึ่ง​ความ​ต้องการ: การ​จัด​กลุ่ม object ที่​ทำงาน​ด้วย​กัน ให้​เป็น​หน่วย​เดียว​ที่​ระบบ​จัดการ​ได้​อย่าง​รวดเร็ว​และ​เชื่อถือ​ได้ — นี่​คือ​หน้าที่​ของ AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ที่​จัด​กลุ่ม object รอบๆ invariant พร้อม​ทำ​หน้าที่​เป็น consistency และ concurrency boundary ไป​ใน​ตัว

Aggregate มอบ​ประโยชน์​สาม​อย่าง:

  1. ออกแบบ​รอบ domain invariant — invariant คือ​กฎ​ที่ “ต้อง​เป็น​จริง​เสมอ” เช่น bid ที่​ชนะ​การ​ประมูล​ต้อง​ถูกวาง​ก่อน​การ​ประมูล​ปิด เมื่อ object ทุก​ตัว​ที่​เกี่ยว​กับ invariant อยู่​ใน Aggregate เดียวกัน เรา​จึง​เทียบ​และ​ประสาน​สถานะ​ของ​มัน​ได้​ใน​ที่​เดียว
  2. ยก​ระดับ​การ abstraction — เรียก order กับ order line รวม​กัน​ว่า “order” ก้อน​เดียว ทำให้​สื่อสาร​และ​คิด​ใน​ภาพ​รวม​ได้​ง่าย​ขึ้น
  3. เป็น consistency boundary — กำหนด​ว่า​ข้อมูล​ส่วน​ไหน​ต้อง​ถูกต้อง​ตรง​กัน​ภายใน transaction เดียว
invariant ใน​บท​นี้​ต่าง​จาก​บท Value Object อย่างไร

invariant ที่​เจอ​ใน​บท Value ObjectValue Objectอ็อบเจ็กต์ที่​สำคัญ​ที่ “ค่า” ของ​มัน ไม่มี​ตัวตน​เฉพาะ สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เท่า​กัน ควร​เป็น immutable (เปลี่ยน​ค่า​ไม่​ได้) เช่น เงิน, วัน​ที่, พิกัดTactical Design ใช้​กับ entity เดี่ยวๆ แต่ invariant ใน​บท​นี้​มัก คร่อม object หลาย​ตัว จึง​ต้อง​ใช้ Aggregate มา​ล้อม​ไว้​ด้วย​กัน

ถ้า​เรา​เอา transactional boundary ก้อน​เดียว​ไป​ครอบ​ทั้ง model ปัญหา​คือ​ใน collaborative domain การ​แก้ไข​ที่​ไม่​เกี่ยวข้อง​กัน​เลย​กลับ​ชน​กัน จน​เกิด locking ที่ database ดัง​รูป​ที่ 19-9: ผู้​ใช้ A เพิ่ม​ที่​อยู่​ให้​ลูกค้า ขณะ​ที่​ผู้​ใช้ B เปลี่ยน​สถานะ​คำ​สั่ง​ซื้อ​ของ​ลูกค้า​คน​เดียวกัน — ไม่มี invariant ใด​ที่​ห้าม​ทั้ง​สอง​เกิด​พร้อม​กัน แต่ boundary ที่​ใหญ่​เกิน​ไป​กลับ block อีก​ฝ่าย​ไว้

รูปที่ 19-9: transactional boundary ที่ใหญ่เกินไป ครอบทั้ง Customer และ Order ทำให้การแก้ name ไป block การอัปเดตสถานะ order

ใน​ทาง​กลับ​กัน ถ้า​ไม่มี boundary เลย ก็​เปิด​ช่อง​ให้ invariant พัง​ได้ ทางออก​คือ​จัด boundary ให้ ตรง​กับ invariant: Customer กับ Order เป็น​คนละ Aggregate เพราะ​ไม่มี invariant ใด​ที่​ผูก​ทั้ง​คู่​เข้า​ด้วย​กัน

รูปที่ 19-11: จัด transactional boundary ให้ตรงกับ invariant — Order (กับ Item ภายใน) เป็น1 Aggregate และ Customer เป็นอีก Aggregate แยกกัน บันทึก/ดึงเป็นหน่วยเดียว

เพราะ Aggregate ถูก​บันทึก​และ​ดึง​แบบ atomic เป็น​ก้อน​เดียว กฎ​ที่​คร่อม2 Aggregate จึง ไม่ consistent ทันที แต่​จะ​เป็น eventual consistencyEventual Consistencyความ​ถูกต้อง​ที่ “ตาม​มา​ทีหลัง” ไม่ใช่​ทันที ใช้​กับ​การ​อัปเดต​ข้าม Aggregate ผ่าน Domain Event แทน​การ​บังคับ​ให้​ตรง​กัน​ใน​ทันที​ภายใน transaction เดียวTactical Design แทน — Aggregate ที่​ถูก​อัปเดต​รับผิดชอบ​แค่​ความ​ถูกต้อง​ภายใน​ตัวเอง บาง​ครั้ง​จึง​ถือ​ข้อมูล​เก่า (stale) ที่ copy มา​จาก Aggregate อื่น

ตัวอย่าง​นโยบาย​สะสม​แต้ม: ถ้า​ลูกค้า​ใช้​จ่าย​เกิน $100 ใน​ปี​ที่​ผ่าน​มา จะ​ได้​ส่วนลด 10% ใน​การ​สั่ง​ซื้อ​ทุก​ครั้ง​ถัด​ไป — Order กับ Loyalty เป็น​คนละ Aggregate เมื่อ​สั่ง​ซื้อ เรา​อัปเดต Order ใน transaction เดียว แล้ว publish Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design OrderCreated ให้ Loyalty subscribe ไป​อัปเดต​ทีหลัง​ใน transaction ที่​สอง (รูป​ที่ 19-14)

รูปที่ 19-14: eventual consistency ผ่าน domain event — Transaction 1 สั่งซื้อแล้ว publish OrderCreated เข้า queue, Transaction 2 รับ event ไปอัปเดต Loyalty Aggregate

ค่า​เริ่มต้น​คือ​บันทึก1 Aggregate ต่อ1 transaction

อย่า​สับสน​ระหว่าง “save” กับ “load/create” — โหลด​หลาย Aggregate ใน transaction เดียว​ได้ และ​สร้าง Aggregate ใหม่​หลาย​ตัว​พร้อม​กัน​ก็ได้ (object ที่​เพิ่ง​สร้าง​ยัง​ไม่​ก่อ concurrency conflict) แต่​การ save ควร​เป็น Aggregate เดียว​ต่อ1 transaction เป็น​ค่า​เริ่มต้น​ไว้​ก่อน​เสมอ — จะ​ข้าม​กฎ​นี้​ก็​ต่อ​เมื่อ​ปรึกษา​กับ​ธุรกิจ​แล้ว​ว่า​ได้​ประโยชน์​ที่​คุ้ม​กับ​ความ​เสี่ยง​จริง​เท่านั้น


โดย​ทั่วไป Aggregate ที่​เล็ก​กว่า​ทำให้​ระบบ​เร็ว​และ​เชื่อถือ​ได้​กว่า เพราะ​ถ่าย​โอน​ข้อมูล​น้อย​ลง​และ​โอกาส concurrency conflict ต่ำ​ลง ให้​เริ่ม​จาก​เล็ก​ก่อน​แล้ว​ค่อยๆ พิสูจน์​ว่า​ทำไม​ต้อง​เพิ่ม​แต่ละ​แนวคิด​เข้าไป ผล​เสีย​ของ Aggregate ใหญ่​มี​สาม​ข้อ:

  • performance ตก — ยิ่ง​สมาชิก​เยอะ ยิ่ง​โหลด/บันทึก​ข้อมูล​มาก โดย​เฉพาะ​เมื่อ Aggregate คร่อม​หลาย​ตาราง​หรือ​หลาย document (เพิ่ม query/join)
  • เสี่ยง concurrency conflict — Aggregate ใหญ่​มัก​มี​หลาย​หน้าที่ เกี่ยวข้อง​กับ​หลาย use case โอกาส​ที่​ผู้​ใช้​หลาย​คน​แก้​พร้อม​กัน​จึง​สูง
  • scale ยาก — การ​คร่อม​หลาย​ตาราง​คือ coupling ที่​ระดับ database ทำให้​ย้าย/แบ่ง partition ข้อมูล​ลำบาก (เทรนด์ polyglot persistence ต้องการ Aggregate ที่ decouple กัน​ดี)
อย่า​พึ่ง Aggregate เล็ก​อย่าง​เดียว​เพื่อ performance

การ​ทำ Aggregate ให้​เล็ก​ไม่ใช่​กระสุน​วิเศษ (silver bullet) ของ performance คุณ​ยัง​ต้อง​วัด round-trip และ database performance ของ​จริง เพื่อ​เข้าใจ​พฤติกรรมการ​เข้าถึง​ข้อมูล​อย่าง​แท้จริง และ​ต้อง ซื่อตรง​ต่อ domain ด้วย — ขนาด​เป็น​เพียง​ปัจจัย​หนึ่ง ไม่ใช่​เกณฑ์​เดียว​ของ​การ​ออกแบบ​ที่​ดี


หนังสือ​ใช้​เว็บ​ประมูล​ออนไลน์​ชื่อ eBidder (คล้าย eBay) ใน Bounded Context ชื่อ Listing เพื่อ​สาธิต​หลักการ — มี object เช่น Listing, Auction, WinningBid, Bid, Question, Answer, Member, Seller, Category, ShippingMethod, PaymentMethod, Watches กฎ​สำคัญ​บาง​ข้อ:

  • listing ต้อง​อยู่​ใน​อย่าง​น้อย1 category และ​เสนอ​อย่าง​น้อยหนึ่ง​วิธี​จ่าย​เงิน​และ​หนึ่ง​วิธี​จัด​ส่ง
  • listing ขาย​ผ่าน auction ซึ่ง​มี​วัน​เริ่ม–สิ้นสุด และ​ติดตาม winning bid
  • auction ต้อง active และ winning bid ปัจจุบัน​ต้อง​น้อย​กว่า​จำนวน​ที่​จะ bid ใหม่

วิเคราะห์​ตาม invariant: Question/Answer ไม่มี invariant ผูก​กับ Listing (แค่​อ้าง​ผ่าน ID) จึง​เป็น Aggregate ของ​ตัวเอง; Auction กับ WinningBid มี invariant ร่วม​กัน (ราคา​ปัจจุบัน​นิยาม​โดย winning bid) จึง​อยู่ Aggregate เดียวกัน; Bid เป็น​เหตุการณ์​ใน​อดีต ไม่​ผูก invariant ใด เป็น Aggregate เดี่ยว; Member, Seller, Watches ก็​แยก​เป็น Aggregate ของ​ตัวเอง

อย่า​ยึด HAS-A และ​อย่า​หลง​ว่า Aggregate คือ ‘ที่​เก็บ collection’

Listing HAS Question และ HAS Auction แต่​ไม่​ได้​แปล​ว่า​ต้อง​อยู่ Aggregate เดียวกัน — HAS-A เป็น​เรื่อง​ของ data model ไม่ใช่​ของ invariant การ​ลาก collection ของ bid เข้า​มา​ใน auction “เพราะ auction มี bid” คือ​กับดัก​ที่​ทำให้ object graph บวม​และ​ได้ Aggregate ที่​ไร้​ประโยชน์ จง พิสูจน์​การ​จัด​กลุ่ม​ทุก​ครั้ง​ด้วย invariant ไม่ใช่​ด้วย​ความ​เกี่ยวข้อง​กัน

จง​ออกแบบ Aggregate จาก​มุม​ของ business use case ไม่ใช่​จาก​ชีวิต​จริง — ถาม​ว่า invariant อะไร​ต้อง​เป็น​จริง​เพื่อ​ทำ use case นั้น​ให้​สำเร็จ ยก​ตัวอย่าง use case การ bid (PlaceBidFor) ไม่​ต้อง​ใช้​ข้อมูล category หรือ​คำ​บรรยาย​ของ listing เลย นี่​คือ​เหตุผล​ที่ listing กับ auction เป็น​คนละ Aggregate

BidOnAuctionService.cs
public void Bid(Guid auctionId, Guid memberId, decimal amount)
{
var member = _memberService.GetMember(memberId);
if (member.CanBid)
{
var auction = _auctions.FindBy(auctionId); // โหลดแค่ Auction Aggregate
var offer = new Offer(memberId, new Money(amount), _clock.Time());
auction.PlaceBidFor(offer, _clock.Time()); // พฤติกรรมอยู่ที่ root
}
}

เพื่อ​ให้ Aggregate consistent เสมอ ส่วนประกอบ​ภายใน​ต้อง​ไม่​ถูก share ออก​ไป​ทั่ว model หรือ​เปิด​ให้ service layer แตะตรงๆ ทางออก​คือ​เลือก entity หนึ่ง​ตัว​เป็น Aggregate RootAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น “ประตู​เดียว” เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design — การ​สื่อสาร​กับ Aggregate ทั้งหมด​ต้อง​ผ่าน root เท่านั้น

รูปที่ 19-22: Auction เป็น Aggregate Root — เป็นประตูเข้าสู่กลุ่มที่มี Winning Bid, Max Bid, Current Price และ Starting Price อยู่ภายใน

ลอง​เทียบ​ให้​เห็น​ความ​ต่าง: ถ้า​ไม่มี root ผู้​เรียก​จะ​เข้าถึง​โครงสร้าง​ภายใน​และ ข้าม พฤติกรรม​ไป​แก้​สมา​ชิ​กตรงๆ ได้ — ทำลาย invariant ทันที:

การ bypass พฤติกรรม (อันตราย)
// ฝ่าฝืน invariant ที่ว่า แต่ละ item ห้ามเกิน 10 ชิ้น
basket.Items.Find(x => x.ProductId == productId).Quantity = newQuantity;
เรียก​ผ่าน root (ปลอดภัย)
// ภายใน ChangeQuantityOf มี logic บังคับ invariant (เพดาน 10 ชิ้น)
basket.ChangeQuantityOf(productId, newQuantity);

Aggregate Root จึง​ควร เปิด​เฉพาะ behavioral interface (method ที่​สื่อ​แนวคิด domain เช่น ReduceTheStartingPrice(), CanPlaceBid(), PlaceBidFor()) และ​ซ่อน reference ภายใน​ไว้​เป็น private — หลีก​เลี่ยง getter/setter ที่​เปิด​สถานะ​ภายใน​ออก​มา เพราะ​จะ​ทำให้ object อื่น​ไป​ผูก​ติด​กับ​โครงสร้าง​ภายใน​และ​ขัด​ขวาง​การ refactor model ใน​ภายหลัง

local identity vs global identity

Aggregate Root มี global identity เพราะ​ถูก​อ้าง​จาก​นอก Aggregate ได้ ส่วน​สมาชิก​อื่น​มี​แค่ local identity เพราะ​อยู่​ภายใน​และ​มี​ความหมาย​เฉพาะ​ใน​บริบท​ของ root เท่านั้น

  • อ้าง Aggregate Root อื่น​ด้วย ID เสมอ ไม่ใช่​ด้วย object reference — มิ​ฉะนั้น​การ​โหลด Aggregate หนึ่ง​อาจ​ลาก​อีก​หลาย​ตัว​ตาม​มา (และ​เกิด​ปัญหา lazy loading ที่ debug ยาก) เช่น Auction เก็บ​แค่ ItemId (ID ของ Listing) ไม่ใช่ object Listing
  • ห้าม​สิ่ง​ที่​อยู่​นอก Aggregate ถือ reference ไป​ยัง​สมาชิก​ภายใน — ผู้​ใช้ Aggregate ถือ​ได้​แค่ reference ของ root เท่านั้น (รูป​ที่ 19-24)
  • root ส่ง transient reference / สำเนา (copy) ของ object ภายใน​ออก​ไป​ใช้​ชั่วคราว​ใน method เดียว​ได้ แต่​การ​แก้ไขใดๆ ต้อง​วิ่ง​กลับ​มา​ผ่าน root
  • ตรง​ข้าม​กับ​สัญชาตญาณ​เล็กน้อย: object ที่ ไม่ใช่ root ภายใน Aggregate หนึ่ง ถือ reference (ใน​รูป​ของ ID) ไป​ยัง root ของ Aggregate อื่น​ได้ เช่น WinningBid เก็บ ID ของ Member ที่ bid

รูปที่ 19-24: ห้ามผู้ใช้จากนอก Aggregate ถือ reference ไปยังสมาชิกภายใน (Winning Bid, Max Bid, Current Price) — ลูกศรที่ถูกกากบาททับคือสิ่งที่ทำไม่ได้

🚢 Cargo: Handling Event เป็น Aggregate แยก​จาก Cargo

ใน​ระบบ​ขนส่ง CargoAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น “ประตู​เดียว” เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design ถือ Route SpecificationRoute SpecificationValue Object ที่​ระบุ “ความ​ต้องการ​ของ​ลูกค้า”: ต้นทาง ปลายทาง และ​กำหนด​เวลา​ถึง — มี method isSatisfiedBy() ตรวจ​ว่า​แผน​เดินทาง​ตรง​ตาม​นี้​หรือ​ไม่Tactical Design และ ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design (value object) ไว้​ภายใน เพราะ invariant “แผน​เดินทาง​ต้อง​ตอบ​สนอง​ความ​ต้องการ​ของ​ลูกค้า” (isSatisfiedBy) ต้อง​เทียบ​ทั้ง​สอง​ใน​ที่​เดียว — จึง​เป็น Aggregate เดียวกัน

แต่ Handling EventHandling Eventการ​บันทึก​การ​จัดการ​สินค้า​จริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยก​ต่างหาก” จาก Cargo เพราะ​มี​ปริมาณ​มาก​และ​ต้อง​ประมวล​ผล​แบบ asyncTactical Design (บันทึก​การ LOAD/UNLOAD จริง​ที่​ท่าเรือ) ถูก​แยก​เป็น คนละ Aggregate เพราะ​มัน​คือ​เหตุการณ์​ใน​อดีต​ที่​มี​ปริมาณ​มหาศาล​และ​ประมวล​ผล​แบบ async — มัน​อ้าง Cargo ผ่าน Tracking IDTracking IDรหัส​ติดตาม​ที่​ไม่​ซ้ำ​ของ Cargo ใน​ตัวอย่าง​การ​ขนส่ง ใช้​เป็น identity ของ Cargo ตั้งแต่​ตอน​จอง​จน​ส่ง​มอบTactical Design ไม่ใช่​ผ่าน object reference ซึ่ง​เป็น​รูปแบบ​เดียว​กับ​เคส Bid ใน eBidder พอดี

🛒 e-commerce: Order กับ Loyalty เป็น eventual consistency

Order (พร้อม OrderLine ภายใน) เป็น Aggregate หนึ่ง โดย​มี invariant “ยอด​รวม​ต้อง​เท่ากับ​ผล​รวม​ของ line” บังคับ​ใน transaction เดียว ส่วน​แต้ม​สะสม (Loyalty) เป็น​อีก Aggregate — เมื่อ​สั่ง​ซื้อ​สำเร็จ Order publish OrderCreated แล้ว handler ค่อย​ไป​อัปเดต Loyalty ใน transaction ถัด​ไป

ลูกค้า​ยินดี​รอ​ให้​ส่วนลด 10% มี​ผล​ใน “วัน​ทำการ​ถัด​ไป” ได้ (โดย​มี SLA กำกับ) จึง​ไม่​จำเป็น​ต้อง consistent ทันที — แลก​กับ​การ​ที่​เรา​ไม่​ต้อง save object จำนวน​มากรวม​กัน​ใน transaction เดียว


Persistence — มี​เพียง Aggregate Root เท่านั้น​ที่ query จาก database ได้ตรงๆ object ภายใน​เข้าถึง​ผ่าน root เสมอ และ​มี หนึ่ง RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design ต่อ1 Aggregate Root ที่​บันทึก/ดึง ทั้ง Aggregate เป็น​หน่วย​เดียว (รูป​ที่ 19-12) การ​ลบ root ต้อง​ลบ object ลูก​ทั้งหมด​ใน transaction เดียวกัน เพราะ order line ไม่มี​ความหมาย​ถ้า​ไม่มี order

รูปที่ 19-12: Order คือ Aggregate Root — เป็นจุดเข้าเดียว (Change item quantity) และทั้ง Aggregate ถูกบันทึก/ดึงเป็นก้อนเดียวจาก database

สำหรับ business use case ให้​ดึง “ทั้ง Aggregate” เท่านั้น แต่​สำหรับ การ​ทำ​รายงาน (reporting) ไม่​ต้อง​สน Aggregate — query ตรง​ที่ database ไป​เติม thin view model ได้​เลย

ระวัง lazy loading: มัน​ทำให้​เกิด​ปัญหา select n+1 เช่น Booking ที่​มี​แขก 50 คน วน loop ส่ง​อีเมล อาจ​ยิง query ถึง 51 ครั้ง (Martin Fowler เขียน​เรื่อง​นี้​ไว้​ละเอียด) ถ้า Aggregate เล็ก​พอ​อยู่​แล้ว ก็​แทบ​ไม่​ต้อง​พึ่ง lazy loading เลย

Transactional consistency ภายใน — Aggregate ต้อง commit หรือ roll back ทั้ง​ก้อน​ใน transaction เดียว ภายใน​ขอบเขต​นี้ object อ้าง​ถึงกัน​ด้วย direct reference ได้ (เช่น Auction ถือ WinningBid) จึง​เห็น​ค่า​ล่าสุด​ของ​กัน​ทันที นอก ขอบเขต​ให้​ใช้ eventual consistency แทน — โดย​ทั่วไป implement ด้วย asynchronous domain event ผ่าน messaging (เช่น NServiceBus, Kafka)

OrderApplicationService.cs (publish event ใน transaction)
public void PlaceOrder(Guid customerId, IEnumerable<Product> products)
{
var customer = _customerRepository.FindBy(customerId);
var order = customer.AddOrder(products);
_bus.Publish(new OrderCreated(order)); // publish เป็นส่วนหนึ่งของ transaction
// commit
_unitOfWork.SaveChanges();
}

Concurrency — root ปกป้อง invariant จาก​การ bypass ได้ แต่​กัน​ผู้​ใช้​หลาย​คน​แก้​พร้อม​กัน​ไม่​ได้ มี​สอง​แบบ: optimistic (สมมติ​ว่า​ไม่​ชน, “last change wins”) และ pessimistic (ต้องการ​ให้​ตรง​กับ​ตอน​ดึง​มา) วิธี​ที่​นิยม​คือ​ใส่ Version ไว้​ที่ root แล้ว​เทียบ​เลข version กับ​ใน database ก่อน commit ถ้า​ไม่​ตรง​ให้​โยน exception

LoyaltyRepository.cs (optimistic concurrency)
public void Save(Loyalty loyalty)
{
var currentVersion = GetCurrentVersionOf(loyalty);
if (currentVersion != loyalty.Version)
throw new OptimisticConcurrencyException(/* ... */);
// เลข version ตรงกัน — บันทึกต่อได้
}
สรุป​แก่น​ของ​บท​นี้

Aggregate = consistency boundary ที่​จัด​กลุ่ม domain object รอบ invariant ไม่ใช่​รอบ HAS-A หรือ UI; Aggregate Root คือ​ประตู​เดียว​ที่​บังคับ invariant และ​เป็น​ตัว​เดียว​ที่​มี global identity; อ้าง​ถึง Aggregate อื่น​ด้วย ID เสมอ, บันทึก 1 Aggregate ต่อ1 transaction ผ่าน repository, และ​ใช้ eventual consistency ผ่าน domain event กับ​กฎ​ที่​คร่อม​หลาย Aggregate


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

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

  • Aggregate — นิยาม​และ​หลักการ​ออกแบบ Aggregate โดยตรง ต่อยอด​จาก​เรื่อง consistency boundary ที่​บท​นี้​อธิบาย
  • Entity — Aggregate Root คือ Entity ที่​ถูก​เลือก​ให้​เป็น​ประตู​เดียว​เข้า​สู่​กลุ่ม และ​มี global identity ตาม​ที่​บท​นี้​กล่าว​ถึง
  • Repository — กติกา “1 Repository ต่อ1 Aggregate Root” ที่​ใช้​บันทึก/ดึง​ทั้ง Aggregate เป็น​หน่วย​เดียว
  • Domain Events — กลไก​หลัก​ที่​ใช้​ทำ eventual consistency ระหว่าง Aggregate เช่น​เคส OrderCreated ที่​อัปเดต Loyalty
  • Unit of Work — ประสาน transaction ตอน commit ให้ Aggregate ถูก save เป็น​ก้อน​เดียว​ตาม​ที่​บท​นี้​เน้น​ย้ำ

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

ข้อ 1 / 4

เกณฑ์ที่ ‘พื้นฐานที่สุด’ ในการตัดสินว่า object กลุ่มไหนควรเป็น Aggregate เดียวกันคืออะไร?