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

Tactical Design ด้วย Domain Event

ใน​บท​ก่อนๆ เรา​ได้​เห็น​บทบาท​ของ Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design มา​บ้าง​แล้ว — มัน​คือ “บันทึก​ของ​บาง​สิ่ง​ที่​มี​ความ​สำคัญ​ทาง​ธุรกิจ​ซึ่ง​เกิด​ขึ้น​แล้ว” ภายใน Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design ใน​เชิงกลยุทธ์ Domain Event เป็น​เครื่องมือ​ชิ้น​สำคัญ แต่​บ่อย​ครั้ง​มัน​ถูก “คิด​ขึ้น​เป็น​รูป​เป็น​ร่าง” จริงๆ ใน​ช่วง​ของ tactical design และ​กลาย​เป็น​ส่วน​หนึ่ง​ของ Core DomainCore Domainsubdomain ที่​สำคัญ​และ​สร้าง​ความ​ได้​เปรียบ​ใน​การ​แข่งขัน​มาก​ที่สุด เป็น​เหตุผล​ที่​เรา​ต้อง​สร้าง​ซอฟต์แวร์​เอง​แทน​การ​ซื้อ ควร​ทุ่ม​ทีม​เก่ง​ที่สุด​ลง​ตรง​นี้Strategic Design ของ​เรา บท​นี้​จะ​ลง​ลึก​ว่า​จะ​ออกแบบ ใช้งาน และ​นำ Domain Event ไป​ปฏิบัติ​อย่างไร​ให้​ได้​ผล

ใน​เชิง​ภาพ​รวม กลไก​การ​ทำงาน​ของ Domain Event เริ่ม​จาก Bounded Context ที่ “เผยแพร่” (publishing) ปล่อย event ออก​มา ผ่าน​กลไก​การ​รับ​ส่ง​ข้อความ (messaging mechanism) ไป​ยัง Bounded Context อื่น​ที่ “สมัคร​รับ” (subscribing) event นั้น

graph LR
  subgraph PUB["Publishing Bounded Context"]
    A1(("Aggregate"))
  end
  E1["Domain Event"]
  MSG["Messaging Mechanism"]
  E2["Domain Event"]
  subgraph SUB["Subscribing Bounded Context"]
    A2(("Aggregate"))
  end
  A1 --> E1 --> MSG --> E2 --> SUB

พลัง​ที่แท้​จริง​ของ Domain Event จะ​เห็น​ได้​ชัด​เมื่อ​พิจารณา​แนวคิด causal consistency (ความ​สอดคล้อง​ตาม​เหตุ​และ​ผล) domain ทาง​ธุรกิจ​จะ​มี causal consistency ก็​ต่อ​เมื่อ การกระทำ​ที่​เกี่ยวพัน​กัน​เชิง​เหตุผล — คือ​การกระทำ​หนึ่ง “เป็น​เหตุ​ให้” เกิด​อีก​การกระทำ​หนึ่ง — ถูก​มอง​เห็น​โดย​ทุก node ของ​ระบบ​กระจาย (distributed system) ใน ลำดับ​เดียวกัน หมายความ​ว่าการ​กระทำ​ที่​ผูก​กัน​เชิง​เหตุผล​ต้อง​เกิด​ตาม​ลำดับ​ที่​กำหนด สิ่ง​หนึ่ง​จะ​เกิด​ไม่​ได้​จนกว่า​อีก​สิ่ง​หนึ่ง​จะ​เกิด​ก่อน บางที​ก็​แปล​ว่า AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ตัว​หนึ่ง​จะ​ถูก​สร้าง​หรือ​แก้ไข​ไม่​ได้ จนกว่า​จะ​แน่ชัด​ว่า​มี​ปฏิบัติการ​บาง​อย่าง​เกิด​ขึ้น​กับ Aggregate อีก​ตัว​แล้ว ลอง​ดู​บทสนทนา​นี้:

  1. ซู post ข้อความ​ว่า “ทำ​กระเป๋า​ตังค์​หาย​แล้ว!”
  2. แกรี ตอบ​ว่า “แย่​จัง​เลย!”
  3. ซู post ต่อว่า “ไม่​ต้อง​ห่วง เจอ​กระเป๋า​แล้ว!”
  4. แกรี ตอบ​ว่า “เยี่ยม​ไป​เลย!”

ถ้า​ข้อความ​เหล่า​นี้​ถูก replicate ไป​ยัง node ต่างๆ แต่​ไม่​เรียง​ตาม​ลำดับ​เหตุผล อาจ​กลาย​เป็น​ว่า​แก​รีตอบ “เยี่ยม​ไป​เลย!” ต่อ​ข้อความ “ทำ​กระเป๋า​ตังค์​หาย​แล้ว!” ทั้ง​ที่ “เยี่ยม​ไป​เลย!” ไม่​ได้​เกี่ยวข้อง​เชิง​เหตุผล​กับ “ทำ​กระเป๋า​ตังค์​หาย​แล้ว!” เลย — และ​นั่น​ไม่ใช่​สิ่ง​ที่​แก​รีอยากให้ซู​หรือ​ใคร​อ่าน​แน่ๆ ฉะนั้น​ถ้า​จัด​ลำดับ​เหตุผล​ไม่​ถูกต้อง domain โดย​รวม​ก็​จะ​ผิด​หรือ​อย่าง​น้อย​ก็​ชวน​เข้าใจ​ผิด สถาปัตยกรรม​แบบ​ที่​เรียง​เหตุผล​เป็น​เส้นตรง​เช่น​นี้​ทำได้​ด้วย​การ​สร้าง​และ​เผยแพร่ Domain Event ที่ “เรียง​ลำดับ​ถูกต้อง”

จาก tactical design นี่เอง​ที่ Domain Event กลาย​เป็น​ของ​จริง​ใน domain model ของ​เรา แล้ว​ถูก​เผยแพร่​และ “บริโภค” (consume) ทั้ง​ภายใน Bounded Context ของ​เรา​เอง​และ​โดย context อื่น มัน​คือ​วิธี​อัน​ทรง​พลัง​ใน​การ​แจ้ง​ให้​ผู้​ที่​สนใจ (interested listeners) ได้​รู้​ถึง​เหตุการณ์​สำคัญ​ที่​เกิด​ขึ้น​แล้ว

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

Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design คือ “บันทึก​ของ​สิ่ง​ที่​เกิด​ขึ้น​แล้ว” และ​มี​ความ​สำคัญ​ทาง​ธุรกิจ การ​ปล่อย event ที่​เรียง​ลำดับ​เหตุ-ผลอย่าง​ถูกต้อง ทำให้​ระบบ​กระจาย​รักษา causal consistency ได้ และ​เปิด​ทาง​ให้ context อื่น​รับรู้ “สิ่ง​ที่​เกิด​ขึ้น” โดย​ไม่​ต้อง​ผูก​กัน​แน่น​จน​เกิน​ไป


ต่อ​ไป​นี้​คือ​ขั้นตอน​ที่​จำเป็น​ใน​การ​ออกแบบ​และ implement Domain Event ใน Bounded Context ของ​เรา​อย่าง​ได้​ผล พร้อม​ตัวอย่าง​วิธี​ใช้งาน​จริง

เริ่ม​จาก interface ขั้น​ต่ำ​ที่ Domain Event ทุก​ตัว “ควร” รองรับ​เรา​อยาก​สื่อ “วัน​และ​เวลา​ที่ event เกิด​ขึ้น” ซึ่ง​ทำได้​ผ่านพรอเ port ี้ OccurredOn รายละเอียด​นี้​ไม่ใช่​สิ่ง​จำเป็น​เด็ดขาด แต่​มัก​เป็น​ประโยชน์ ดังนั้น​ชนิด​ของ Domain Event ต่างๆ ของ​เรา​ก็​มัก​จะ implement interface นี้

DomainEvent.cs
public interface DomainEvent
{
public Date OccurredOn
{
get;
}
}

สิ่ง​ที่​ต้อง​ใส่ใจ​มาก​คือ การ​ตั้ง​ชื่อ ชนิด​ของ Domain Event คำ​ที่​ใช้​ควร​สะท้อน Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design ของ model เพราะ​คำ​เหล่า​นี้​จะ​กลาย​เป็น “สะพาน” เชื่อม​ระหว่าง​สิ่ง​ที่​เกิด​ขึ้น​ใน model กับ​โลก​ภายนอก การ​สื่อสาร​เหตุการณ์​ให้​ดี​จึง​เป็น​เรื่อง​สำคัญ​ยิ่ง

ชื่อ​ชนิด​ของ Domain Event ควร​เป็น “ประโยค​บอก​เหตุการณ์​ใน​อดีต” นั่น​คือ กริยา​ใน​รูป​อดีต ตัวอย่าง​จาก Agile Project Management Context เช่น ProductCreated บอกว่า Scrum product ถูก​สร้าง​ขึ้น​แล้ว ณ เวลา​หนึ่ง​ใน​อดีต Domain Event อื่นๆ ได้แก่ ReleaseScheduled, SprintScheduled, BacklogItemPlanned และ BacklogItemCommitted — แต่ละ​ชื่อ​บอก​อย่าง​ชัดเจน​และ​กระชับ​ว่า​เกิด​อะไร​ขึ้น​ใน Core Domain

graph TD
  PC["ProductCreated"]
  RS["ReleaseScheduled"]
  SS["SprintScheduled"]
  BIP["BacklogItemPlanned"]
  BIC["BacklogItemCommitted"]

ตัว​ที่​บอก​เล่า​เหตุการณ์​ได้​ครบถ้วน​คือ “การ​ผสม​ระหว่าง​ชื่อ​ของ Domain Event กับ​พรอเ port ี้​ของ​มัน” แล้ว​ควร​ใส่​พรอเ port ี้​อะไร? ลอง​ถาม​ตัวเอง​ว่า “อะไร​คือ​สิ่ง​กระตุ้น (stimulus) จาก application ที่​ทำให้ Domain Event นี้​ถูก​เผยแพร่?”

ใน​กรณี​ของ ProductCreated มี​คำ​สั่ง (command) เป็นต้นเหตุ — โดย command คือ “รูปแบบ​อ็อบเจ็กต์ของ​คำขอ​เรียก​เมท็​อด/การกระทำ” command นี้​ชื่อ CreateProduct ฉะนั้น​พูด​ได้​ว่า ProductCreated คือ “ผลลัพธ์​ของ” command CreateProduct

graph LR
  CMD["CreateProduct (command)"] --> EVT["ProductCreated (event)"]

command CreateProduct มี​พรอเ port ี้​หลาย​ตัว: (1) tenantId ที่​ระบุ tenant ผู้​สมัคร​ใช้งาน (2) productId ที่​ระบุ Product ตัว​ที่​กำลัง​สร้าง​อย่าง​เฉพาะ​เจาะจง (3) name ชื่อ​ของ Product และ (4) description คำ​อธิบาย​ของ Product ทุก​พรอเ port ี้​ล้วน​จำเป็นต่อ​การ​สร้าง Product

ดังนั้น Domain Event ชื่อ ProductCreated จึง​ควร​เก็บ​พรอเ port ี้​ทั้งหมด​ที่มา​กับ command ที่​เป็นต้นเหตุ คือ tenantId, productId, name และ description ข้อมูล​ชุด​นี้​จะแจ้ง​ผู้​สมัคร​รับ (subscriber) ได้​ครบถ้วน​และ​ถูกต้อง​ว่า​เกิด​อะไร​ขึ้น​ใน model — Product ถูก​สร้าง​ให้ tenant ที่​ระบุ​ด้วย tenantId ถูก​ระบุ​ตัวตน​ด้วย productId และ​ได้​รับ name กับ description ที่​กำหนด

ตัวอย่าง​ห้า​ตัว​ต่อ​ไป​นี้​ให้​ภาพ​ที่​ดี​ว่า​ควร​ใส่​พรอเ port ี้​ใด​บ้าง​ใน​แต่ละ Domain Event ที่ Agile Project Management Context เผยแพร่​ออก​มา เช่น เมื่อ BacklogItem ถูก commit เข้า​สู่ Sprint ตัว Domain Event BacklogItemCommitted จะ​ถูก​สร้าง​และ​เผยแพร่ โดย​บรรจุ tenantId, backlogItemId ของ BacklogItem ที่​ถูก commit และ sprintId ของ Sprint ที่​มัน​ถูก commit เข้าไป

graph TD
  subgraph PC["ProductCreated"]
    PC1["tenantId · productId · name · description"]
  end
  subgraph SS["SprintScheduled"]
    SS1["tenantId · sprintId · productId · name · description · startsOn · endsOn"]
  end
  subgraph RS["ReleaseScheduled"]
    RS1["tenantId · releaseId · productId · name · description · targetDate"]
  end
  subgraph BIP["BacklogItemPlanned"]
    BIP1["tenantId · backlogItemId · productId · sprintId · story · summary"]
  end
  subgraph BIC["BacklogItemCommitted"]
    BIC1["tenantId · backlogItemId · sprintId"]
  end

ใส่​ข้อมูล​ให้​พอดี อย่า​มาก​เกิน​จน​เสีย​ความหมาย

หัวข้อ​ที่​มีชื่อ​ว่า “ใส่​ข้อมูล​ให้​พอดี อย่า​มาก​เกิน​จน​เสีย​ความหมาย”

อย่าง​ที่​กล่าว​ไว้​ใน​บท​เรื่อง Context MappingContext Mappingกิจกรรมการ​ระบุ Bounded Context และ​นิยาม​ความ​สัมพันธ์​ระหว่าง​กัน (เช่น Partnership, Shared Kernel, ACL) เพื่อ​จัดการ​การ​เชื่อม​ต่อ​ระหว่าง​ทีม/ระบบStrategic Design บาง​ครั้ง Domain Event ก็ “เสริม” (enrich) ด้วย​ข้อมูล​เพิ่มเติม​ได้ ซึ่ง​มี​ประโยชน์​อย่าง​ยิ่ง​กับ​ผู้บริโภค​ที่​ไม่​อยาก​ย้อน​กลับ​มา query Bounded Context ของ​เรา​เพื่อ​เอา​ข้อมูล​ที่​ต้องการ ถึง​อย่าง​นั้น​ก็​ต้อง​ระวัง อย่า​ยัด​ข้อมูล​ลง​ไป​มาก​จน event เสีย​ความหมาย

ลอง​พิจารณา​ปัญหา​ของ​การ​ให้ BacklogItemCommitted บรรจุ “สถานะ​ทั้งหมด” ของ BacklogItem — ตาม Domain Event ตัว​นี้ จริงๆ แล้ว​เกิด​อะไร​ขึ้น​กัน​แน่? ข้อมูล​ส่วนเกิน​ทั้งหมด​อาจ​ทำให้​ไม่​ชัดเจน เว้น​แต่​จะ​บังคับ​ให้​ผู้บริโภค​เข้าใจ​องค์​ประกอบ BacklogItem ของ​เรา​อย่าง​ลึกซึ้ง หรือ​ลอง​คิดถึง​การ​ใช้ BacklogItemUpdated ที่​บรรจุ​สถานะ​ทั้งหมด​แทน BacklogItemCommitted — เกิด​อะไร​ขึ้น​กับ BacklogItem ก็​ยิ่ง​คลุมเครือ เพราะ​ผู้บริโภค​ต้อง​เอา BacklogItemUpdated ตัว​ล่าสุด​ไป​เทียบ​กับ BacklogItemUpdated ตัว​ก่อนหน้า เพื่อ​จะ​เข้าใจ​ว่าจริงๆ เกิด​อะไร​ขึ้น

graph LR
  CMD["CommitBacklogItemToSprint (command) — backlogItem · sprint"] --> AGG(("BacklogItem Aggregate"))
  AGG --> EVT["BacklogItemCommitted — tenantId · backlogItemId · sprintId"]

เดิน​ตาม​สถานการณ์​จริง: จาก Command ถึง Event ที่​ถูก​บันทึก

หัวข้อ​ที่​มีชื่อ​ว่า “เดิน​ตาม​สถานการณ์​จริง: จาก Command ถึง Event ที่​ถูก​บันทึก”

เพื่อ​ให้​เห็น​การ​ใช้ Domain Event อย่าง​ถูกต้อง​ชัดเจน​ขึ้น ลอง​เดิน​ผ่าน​สถานการณ์​หนึ่ง: เจ้าของ​ผลิตภัณฑ์ (product owner) commit BacklogItem หนึ่ง​เข้า​สู่ Sprint ตัว command เอง​ทำให้ BacklogItem และ Sprint ถูก​โหลด​ขึ้น​มา จาก​นั้น command ถูก​สั่ง​ให้​ทำงาน​บน BacklogItem AggregateAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น “ประตู​เดียว” เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design ส่ง​ผล​ให้​สถานะ​ของ BacklogItem ถูก​แก้ไข แล้ว Domain Event BacklogItemCommitted จึง​ถูก​เผยแพร่​ออก​มา​เป็น​ผลลัพธ์

สิ่ง​สำคัญ​คือ Aggregate ที่​ถูก​แก้ไข​กับ Domain Event ต้อง​ถูก​บันทึก​ไป​ด้วย​กัน​ใน transaction เดียวกัน ถ้า​ใช้​เครื่องมือ object-relational mapping ก็​จะบันทึก Aggregate ลง​ตาราง​หนึ่ง​และ​บันทึก Domain Event ลง​ตาราง event store อีก​ตาราง​หนึ่ง แล้ว​จึง commit transaction นั้น ถ้า​ใช้ Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture สถานะ​ของ Aggregate ก็​จะ​ถูก​แทน​ด้วย​ตัว Domain Event ทั้งหมด​อยู่​แล้ว ไม่​ว่า​จะ​ทาง​ใด การ persist Domain Event ลงใน event store ก็​ช่วย​รักษา​ลำดับ​เหตุ-ผล​ของ​มัน​เทียบ​กับ​สิ่ง​ที่​เกิด​ขึ้น​ทั่ว​ทั้ง domain model

graph TD
  CMD["CommitBacklogItemToSprint"] --> AGG(("BacklogItem Aggregate"))
  AGG --> EVT["BacklogItemCommitted"]
  AGG -->|"same transaction"| DB[("Event Store")]
  EVT -->|"same transaction"| DB

เมื่อ Domain Event ถูก​บันทึก​ลง event store แล้ว ก็​จะ​ถูก​เผยแพร่​ไป​ยัง​ผู้​ที่​สนใจ​ทุก​ฝ่าย ทั้ง​ภายใน Bounded Context ของ​เรา​เอง​และ​ไป​ยัง Bounded Context ภายนอก นี่​คือ​วิธี​บอก​โลก​ว่า​มี​บาง​สิ่ง​ที่​ควร​ค่า​แก่​การ​สนใจ​เกิด​ขึ้น​แล้ว​ใน Core Domain

graph LR
  AGG(("BacklogItem Aggregate")) --> EVT["BacklogItemCommitted"]
  EVT -->|"Saved"| DB[("Event Store")]
  DB -->|"Published"| SUB(("Subscribing Bounded Context"))

พึง​ระวัง​ว่า การ​บันทึก Domain Event ตาม​ลำดับ​เหตุ-ผล ไม่​ได้​รับประกัน ว่า​มัน​จะ​ไป​ถึง node ปลายทาง​ที่​กระจาย​อยู่​ใน​ลำดับ​เดียวกัน ฉะนั้น​จึง​เป็น​ความ​รับผิดชอบ​ของ Bounded Context ฝั่ง​ผู้บริโภค​ที่​จะ “รับรู้​ลำดับ​เหตุ-ผล​ที่​ถูกต้อง” เอง อาจ​เป็น​ชนิด​ของ Domain Event เอง​ที่​บ่ง​บอก​ลำดับ​เหตุผล หรือ​อาจ​เป็น metadata ที่​แนบ​มา​กับ Domain Event เช่น sequence หรือ causal identifier ตัว​ระบุ​นี้​จะ​บอกว่า​อะไร​เป็น​เหตุ​ของ Domain Event นี้ และ​ถ้า​ยัง​ไม่​เห็น​เหตุ​นั้น ผู้บริโภค​ก็​ต้อง​รอ — ยัง​ไม่​นำ event ที่​เพิ่ง​มา​ถึง​ไป​ใช้​จนกว่า​เหตุ​ของ​มัน​จะ​มา​ถึง ใน​บาง​กรณี​ก็​เป็น​ไป​ได้ที่​จะ “เพิกเฉย” Domain Event ที่​ล่าช้า​ซึ่ง​ถูก​แทนที่​ด้วย​การกระทำ​ที่​ผูก​กับ event ตัว​หลัง​ไป​แล้ว — กรณี​นี้​ลำดับ​เหตุผล​มี​ผลกระทบ​ที่ “ละทิ้ง​ได้”

มี​อีก​ประเด็น​เกี่ยว​กับ​สิ่ง​ที่​อาจ​เป็น​เหตุ​ให้​เกิด Domain Event แม้​บ่อย​ครั้ง​จะ​เป็น command จาก​ผู้​ใช้​ที่​ปล่อย​ออก​มา​จาก user interface แต่​บาง​ครั้ง Domain Event ก็​เกิด​จาก​แหล่ง​อื่น​ได้ เช่น​จาก timer ที่​หมด​เวลา — สิ้น​วัน​ทำการ สิ้น​สัปดาห์ สิ้น​เดือน หรือ​สิ้น​ปี ใน​กรณี​เช่น​นี้ จะ “ไม่ใช่ command” ที่​เป็น​เหตุ​ของ event เพราะ​การ​สิ้นสุด​ของ​ช่วง​เวลา​เป็น “ข้อเท็จจริง” เรา​ปฏิเสธ​ความ​จริง​ว่า​ช่วง​เวลา​หนึ่ง​หมด​ลง​ไม่​ได้ และ​ถ้า​ธุรกิจ​สนใจ​ข้อเท็จจริง​นี้ การ​หมด​เวลา​ก็​ถูก​จำลอง​เป็น Domain Event ไม่ใช่ command

ยิ่ง​กว่า​นั้น ช่วง​เวลา​ที่​หมด​ลง​เช่น​นี้​มัก​มีชื่อ​บรรยาย​ที่​จะ​กลาย​เป็น​ส่วน​หนึ่ง​ของ Ubiquitous Language เช่น FiscalYearEnded (สิ้น​ปีงบประมาณ) อาจ​เป็น event สำคัญ​ที่​ธุรกิจ​ต้อง​ตอบ​สนอง หรือ​เวลา 16:00 น. ใน​ตลาด​วอลล์สตรีท​เป็น​ที่​รู้จัก​ใน​ชื่อ “Markets Closed” ไม่ใช่​แค่ “บ่าย​สี่​โมง” — เรา​จึง​มีชื่อ​สำหรับ Domain Event ที่​อิง​เวลา​นั้น​โดย​เฉพาะ

graph LR
  TIMER["Timer หมดเวลา (ข้อเท็จจริง)"] --> EVT["FiscalYearEnded"]
  EVT --> SUB(("Subscribing Bounded Context"))

ข้อ​แตก​ต่าง​สำคัญ​คือ command ถูก “ปฏิเสธ” ได้ ใน​บาง​กรณี เช่น​เพราะ​อุปทาน​และ​ความ​พร้อม​ของ​ทรัพยากร (สินค้า เงิน​ทุน ฯลฯ) หรือ​การ​ตรวจสอบ​กฎ​ทาง​ธุรกิจอื่นๆ — command อาจ​ถูก​ปฏิเสธ แต่ Domain Event เป็น​เรื่อง​ของ​ประวัติศาสตร์​และ​ถูก​ปฏิเสธ​ใน​เชิง​ตรรกะ​ไม่​ได้ ถึง​อย่าง​นั้น เพื่อ​ตอบ​สนอง​ต่อ Domain Event ที่​อิง​เวลา application อาจ​ต้อง​สร้าง command หนึ่ง​หรือ​หลาย​ตัว​เพื่อ​ขอ​ให้​ระบบ​ทำ​ชุด​ของ​การกระทำ​ต่อ​ไป

🛒 เทียบ​กับ e-commerce

เมื่อ​ลูกค้า​ยืนยัน​คำ​สั่ง​ซื้อ command PlaceOrder ถูก​สั่ง​ทำงาน​บน Order AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ซึ่ง​บังคับ​ใช้ InvariantInvariantกฎ​ทาง​ธุรกิจ​ที่ “ต้อง​เป็น​จริง​เสมอ” ภายใน Aggregate เช่น ยอด​รวม​คำ​สั่ง​ซื้อ​ต้อง​เท่ากับ​ผล​รวม​ของ​รายการ​สินค้า — ต้อง​ถูกต้อง​ภายใน transaction เดียวTactical Design (ยอด​รวม​ต้อง​ตรง​กับ​รายการ​สินค้า) แล้ว​ปล่อย Domain Event OrderPlaced พร้อม​พรอเ port ี้​ที่​จำเป็น (orderId, customerId, รายการ​สินค้า) บันทึก Aggregate กับ event ใน transaction เดียวกัน แล้ว​เผยแพร่​ให้ context “คลัง​สินค้า” และ “การ​จัด​ส่ง” รับ​ไป​ทำงาน​ต่อ — โดย​ไม่​ผูก​กัน​แน่น​จน​เกิน​ไป ส่วน “สิ้น​วัน​ทำการ” ก็​จำลอง​เป็น Domain Event BusinessDayEnded ที่​อาจ​กระตุ้น​การ​สรุป​ยอด​ประจำ​วัน


Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture อธิบาย​ได้​ว่า​คือ​การ persist ทุก Domain Event ที่​เคย​เกิด​กับ Aggregate instance หนึ่ง ไว้​เป็น​บันทึก​ของ​สิ่ง​ที่​เปลี่ยน​ไป​กับ Aggregate instance นั้น แทนที่​จะ persist สถานะ​ของ Aggregate ทั้ง​ก้อน เรา​กลับ​เก็บ Domain Event แต่ละ​ตัว​ที่​เกิด​ขึ้น​กับ​มัน​ทั้งหมด ลอง​ไล่​ดู​ว่า​รองรับ​เรื่อง​นี้​อย่างไร

Domain Event ทั้งหมด​ที่​เกิด​กับ Aggregate instance หนึ่ง เรียง​ตาม​ลำดับ​ที่​เกิด​จริง รวม​กัน​เป็น event stream ของ​มัน event stream เริ่ม​จาก Domain Event ตัว​แรก​ที่​เคย​เกิด​กับ Aggregate instance นั้น และ​ต่อ​เนื่อง​ไป​จนถึง Domain Event ตัว​ล่าสุด เมื่อ​มี Domain Event ใหม่​เกิด​ขึ้น​กับ Aggregate instance หนึ่ง มัน​ก็​ถูก append ต่อ​ท้าย event stream ของ​มัน การ “เล่น​ซ้ำ” (reapply) event stream กลับ​เข้า​สู่ Aggregate ทำให้​สถานะ​ของ​มัน​ถูก สร้าง​ขึ้น​ใหม่ (reconstitute) จาก persistence กลับ​เข้า​สู่​หน่วย​ความ​จำ พูด​อีก​อย่าง​คือ เมื่อ​ใช้ Event Sourcing ตัว Aggregate ที่​ถูกลบ​ออก​จาก​หน่วย​ความ​จำ​ด้วย​เหตุ​ใด​ก็ตาม จะ​ถูก​สร้าง​ขึ้น​ใหม่​ทั้งหมด​จาก event stream ของ​มัน

graph TD
  CMD["CommitBacklogItemToSprint (command)"] --> AGG(("BacklogItem Aggregate"))
  AGG --> E3["BacklogItemCommitted (3)"]
  E1["BacklogItemPlanned (1)"]
  E2["BacklogItemStoryDefined (2)"]
  E1 --> DB
  E2 --> DB
  E3 --> DB[("Event Store")]

ใน​ไดอะแกรม Domain Event ตัว​แรก​ที่​เกิด​คือ BacklogItemPlanned ตัว​ต่อ​มา​คือ BacklogItemStoryDefined และ​ตัว​ที่​เพิ่ง​เกิด​คือ BacklogItemCommitted ตอน​นี้ event stream เต็ม​ประกอบ​ด้วย3 event นี้ เรียง​ตาม​ลำดับ​ที่​บรรยาย​ไว้ Domain Event แต่ละ​ตัว​ที่​เกิด​กับ Aggregate instance หนึ่ง​ล้วน​มี command เป็น​เหตุ เช่น​ที่​อธิบาย​ไป​ก่อนหน้า — ใน​ไดอะแกรม​นี้​คือ command CommitBacklogItemToSprint ที่​เพิ่ง​ถูก​จัดการ และ​ทำให้ Domain Event BacklogItemCommitted เกิด​ขึ้น

ตัว event store ก็​เป็น​เพียง collection หรือ​ตาราง​จัด​เก็บ​แบบ​เรียง​ลำดับ ที่ Domain Event ทั้งหมด​ถูก append เข้าไป เพราะ event store เป็น​แบบ append-only กลไก​จัด​เก็บ​จึง​เร็ว​มาก ทำให้​วางแผน​ได้​ว่า Core Domain ที่​ใช้ Event Sourcing จะมี throughput สูง​มาก latency ต่ำ และ​รองรับ​การ scale ได้​สูง

graph LR
  EVT["BacklogItemCommitted"] --> ES[("Event Store")]
  ES --- T["Stream Id · Stream Version · Event Type · Event Content<br/>backlogItem123 · 1 · BacklogItemPlanned · {…}<br/>backlogItem123 · 2 · BacklogItemStoryDefined · {…}<br/>backlogItem123 · 3 · BacklogItemCommitted · {…}"]
ใส่ใจ​เรื่อง​ประสิทธิภาพ: caching และ snapshot

ถ้า​ประสิทธิภาพ​เป็น​ข้อ​กังวล​หลัก จะ​มี​ประโยชน์​ที่​รู้จัก caching และ snapshot — Aggregate ที่​ทำงาน​เร็ว​ที่สุด​คือ​ตัว​ที่​ถูก cache ไว้​ใน​หน่วย​ความ​จำ ไม่​ต้อง​สร้าง​ใหม่​จาก storage ทุก​ครั้ง​ที่​ใช้ การ​ใช้ Actor model โดย​ให้ actor เป็น Aggregate (ตาม​หนังสือ Reactive Messaging Patterns with the Actor Model ของ Vaughn Vernon) เป็น​วิธี​หนึ่ง​ใน​การ cache สถานะ​ของ Aggregate ไว้

อีก​เครื่อง​มือหนึ่ง​คือ snapshot ซึ่ง​ช่วย​ให้​เวลา​โหลด Aggregate ที่​ถูก​ขับ​ออก​จาก​หน่วย​ความ​จำ​ถูก​สร้าง​ใหม่​ได้​อย่าง​เหมาะสม​ที่สุด โดย​ไม่​ต้อง​โหลด​ทุก Domain Event จาก event stream แปล​ว่า​เก็บ​ภาพ snapshot ของ​สถานะ​บาง​ส่วน​ของ Aggregate (object, actor หรือ record) ไว้​ใน​ฐาน​ข้อมูล รายละเอียด​เชิง​ลึก​หา​อ่าน​ได้​ใน Implementing Domain-Driven Design และ Reactive Messaging Patterns with the Actor Model

ข้อ​ได้​เปรียบ​ที่​ยิ่งใหญ่​ที่สุด​อย่าง​หนึ่ง​ของ Event Sourcing คือ​มัน​เก็บ​บันทึก​ของ ทุก​สิ่ง​ที่​เคย​เกิด​ขึ้น ใน Core Domain ใน​ระดับ​เหตุการณ์​ราย​ตัว ซึ่ง​มี​ประโยชน์​ต่อ​ธุรกิจ​ใน​หลาย​แง่ ทั้ง​ที่​นึกออก​ตอน​นี้ เช่น การ​กำกับ​ดูแล (compliance) และ​การ​วิเคราะห์ (analytics) และ​ที่​ยัง​นึก​ไม่​ถึง​ใน​วัน​นี้ นอกจาก​นี้​ยัง​มี​ข้อ​ได้​เปรียบ​เชิง​เทคนิค เช่น นัก​พัฒนา​สามารถ​ใช้ event stream ตรวจสอบ​เทรนด์​การ​ใช้งาน​และ debug source code ได้

เนื้อหา​เชิง​ลึก​ของ​เทคนิค Event Sourcing หา​อ่าน​ได้​ใน Implementing Domain-Driven Design และ​พึง​ทราบ​ว่า เมื่อ​ใช้ Event Sourcing เรา​แทบ​จะ​ผูกพัน​ที่​จะ​ต้อง​ใช้ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture ด้วย​อย่าง​แน่นอน ซึ่ง​หนังสือ​เล่ม​เดียวกัน​ก็​มี​การ​อภิปราย​หัวข้อ​นี้​ไว้​เช่น​กัน

Event Sourcing มา​พร้อม​ความ​ซับซ้อน

Event Sourcing เป็น​เครื่องมือ​ทรง​พลัง แต่ “แทบ​จะ​ต้อง​ใช้ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture ควบคู่​เสมอ” เพราะ event stream เหมาะ​กับ​การ “เขียน” (append-only) แต่​ไม่​เหมาะ​กับ​การ “อ่าน/query” โดยตรง จึง​ต้อง​สร้าง read model แยก​ต่างหาก อย่า​นำ​มา​ใช้​กับ​ทุก Aggregate — ควร​สงวน​ไว้​ให้ Core Domain ที่​ได้​ประโยชน์​จาก​บันทึก​ประวัติศาสตร์ การ scale สูง และ low latency จริงๆ


ใน​บท​นี้​เรา​ได้​เรียนรู้​วิธี​สร้าง​และ​ตั้ง​ชื่อ Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design ของ​เรา — ตั้ง​ชื่อ​เป็นกริยา​ใน​รูป​อดีต​เพื่อ​บอกว่า “เกิด​อะไร​ขึ้น​แล้ว” — และ​ความ​สำคัญ​ของ​การ​นิยาม​และ implement interface มาตรฐาน​ของ Domain Event (เช่น พรอเ port ี้ OccurredOn) โดย​ย้ำ​ว่าการ​ตั้ง​ชื่อ Domain Event ให้​ดี​นั้น​สำคัญ​เป็น​พิเศษ เพราะ​มัน​คือ​สะพาน​เชื่อม model กับ​โลก​ภายนอก

เรา​ได้​เห็น​วิธี​นิยาม​พรอเ port ี้​ของ Domain Event ให้​บอก​เล่า​เหตุการณ์​ได้​ครบถ้วน โดย​ถาม​ว่า​อะไร​คือ​สิ่ง​กระตุ้น​ที่​ทำให้ event เกิด แล้ว​ใส่​ข้อมูล​ให้​พอดี — มาก​พอ​จะ​มี​ประโยชน์ แต่​ไม่​มาก​จน event เสีย​ความหมาย เรา​ยัง​ได้​รู้​ว่า Domain Event บาง​ตัว​เกิด​จาก command แต่​บาง​ตัว​เกิด​จาก​การ​ตรวจ​พบ​สถานะ​ที่​เปลี่ยน​ไป เช่น​วัน​หรือ​เวลา (ซึ่ง command ปฏิเสธ​ได้ แต่ Domain Event ปฏิเสธ​ไม่​ได้​เพราะ​เป็น​ข้อเท็จจริง​ทาง​ประวัติศาสตร์)

ใน​เชิง​ปฏิบัติ เรา​ได้​เห็น​ว่า​ต้อง​บันทึก Domain Event ลง event store พร้อม​กับ Aggregate ใน transaction เดียวกัน แล้ว​จึง​เผยแพร่​หลัง​บันทึก​เสร็จ ทั้ง​ภายใน Bounded Context ของ​เรา​เอง​และ​ไป​ยัง context ภายนอก โดย​รักษา​ลำดับ​เหตุ-ผล (causal consistency) และ​ให้​ฝั่ง​ผู้บริโภค​รับผิดชอบ​จัด​ลำดับ​ให้​ถูกต้อง สุดท้าย​เรา​ได้​รู้จัก Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture — การ​เก็บ event stream เป็น​แหล่ง​ความ​จริง​ของ Aggregate และ​สร้าง​สถานะ​ขึ้น​ใหม่​ด้วย​การ​เล่น event ซ้ำ พร้อม​ประเด็น​เรื่อง​ประสิทธิภาพ (caching, snapshot) และ​ข้อ​ผูกพัน​ที่​มัก​มา​กับ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture

สำหรับ​การ​ลง​ลึก​เรื่อง Domain Event และ​การ integrate ระบบ รวม​ถึง Event Sourcing เพิ่มเติม ดู​ได้​ใน​หนังสือ Implementing Domain-Driven Design ของ Vaughn Vernon

สรุป​ประเด็น​สำคัญ
  • ตั้ง​ชื่อ​เป็น​อดีต: ProductCreated, BacklogItemCommitted — สะท้อน Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design และ​บอก “สิ่ง​ที่​เกิด​ขึ้น​แล้ว”
  • ใส่​พรอเ port ี้​ให้​พอดี: ถาม​ว่า command อะไร​เป็นต้นเหตุ แล้ว​ใส่​ข้อมูล​ที่​ผู้บริโภค​ต้องการ — ไม่​มาก​จน​เสีย​ความหมาย
  • Command vs Event: command ถูก​ปฏิเสธ​ได้ แต่ Domain Event เป็น​ข้อเท็จจริง​ทาง​ประวัติศาสตร์ บาง event เกิด​จาก timer ไม่ใช่ command
  • บันทึก​ใน transaction เดียว​กับ Aggregate แล้ว​จึง​เผยแพร่ — รักษา causal ordering และ​ปล่อย​ข้าม Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design ผ่าน messaging
  • Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture: เก็บ event stream เป็น​แหล่ง​ความ​จริง สร้าง​สถานะ​ใหม่​ด้วย​การ replay — append-only เร็ว​และ scale ดี แต่​มัก​ต้อง​ใช้ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture ควบคู่
🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

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

  • Domain Events — คำ​อธิบาย​รูปแบบ Domain Events โดยตรง ครอบคลุม​การ​ตั้ง​ชื่อ​เป็น​อดีต การ​เผยแพร่ และ​การนำ​ไป​ใช้งาน ตรง​กับ​แก่น​ของ​บท​นี้​ทั้งหมด
  • Aggregate — ทบทวน​ว่า​ทำไม Domain Event ที่​เผยแพร่​ต้อง​ถูก​บันทึก​ใน transaction เดียว​กับ Aggregate ที่​เป็นต้นเหตุ​ของ​มัน
  • CQRS — แนวคิด​ที่​ผูก​ติด​กับ Event Sourcing แทบ​ทุก​ครั้ง เพราะ event stream แบบ append-only ต้อง​แยก read model ออก​มา​ต่างหาก
  • Event Aggregator — กลไก​รับ-ส่ง (messaging mechanism) รูปแบบ​หนึ่ง​ที่​ช่วย​ให้​ผู้​เผยแพร่​และ​ผู้​สมัคร​รับ Domain Event ไม่​ต้อง​ผูก​กัน​โดยตรง

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

ข้อ 1 / 4

ชื่อชนิดของ Domain Event ควรอยู่ในรูปแบบใด?