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
ทำไมต้องสนใจลำดับเหตุ-ผล (Causal Consistency)
หัวข้อที่มีชื่อว่า “ทำไมต้องสนใจลำดับเหตุ-ผล (Causal Consistency)”พลังที่แท้จริงของ Domain Event จะเห็นได้ชัดเมื่อพิจารณาแนวคิด causal consistency (ความสอดคล้องตามเหตุและผล) domain ทางธุรกิจจะมี causal consistency ก็ต่อเมื่อ การกระทำที่เกี่ยวพันกันเชิงเหตุผล — คือการกระทำหนึ่ง “เป็นเหตุให้” เกิดอีกการกระทำหนึ่ง — ถูกมองเห็นโดยทุก node ของระบบกระจาย (distributed system) ใน ลำดับเดียวกัน หมายความว่าการกระทำที่ผูกกันเชิงเหตุผลต้องเกิดตามลำดับที่กำหนด สิ่งหนึ่งจะเกิดไม่ได้จนกว่าอีกสิ่งหนึ่งจะเกิดก่อน บางทีก็แปลว่า AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยเดียวเพื่อรักษาความถูกต้องของข้อมูล มีขอบเขตชัดเจน และเป็นหน่วยของ transactionTactical Design ตัวหนึ่งจะถูกสร้างหรือแก้ไขไม่ได้ จนกว่าจะแน่ชัดว่ามีปฏิบัติการบางอย่างเกิดขึ้นกับ Aggregate อีกตัวแล้ว ลองดูบทสนทนานี้:
- ซู post ข้อความว่า “ทำกระเป๋าตังค์หายแล้ว!”
- แกรี ตอบว่า “แย่จังเลย!”
- ซู post ต่อว่า “ไม่ต้องห่วง เจอกระเป๋าแล้ว!”
- แกรี ตอบว่า “เยี่ยมไปเลย!”
ถ้าข้อความเหล่านี้ถูก 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 อื่นรับรู้ “สิ่งที่เกิดขึ้น” โดยไม่ต้องผูกกันแน่นจนเกินไป
ออกแบบ ใช้งาน และนำ Domain Event ไปปฏิบัติ
หัวข้อที่มีชื่อว่า “ออกแบบ ใช้งาน และนำ Domain Event ไปปฏิบัติ”ต่อไปนี้คือขั้นตอนที่จำเป็นในการออกแบบและ implement Domain Event ใน Bounded Context ของเราอย่างได้ผล พร้อมตัวอย่างวิธีใช้งานจริง
เริ่มจาก interface ขั้นต่ำที่ Domain Event ทุกตัว “ควร” รองรับเราอยากสื่อ “วันและเวลาที่ event เกิดขึ้น” ซึ่งทำได้ผ่านพรอเ port ี้ OccurredOn รายละเอียดนี้ไม่ใช่สิ่งจำเป็นเด็ดขาด แต่มักเป็นประโยชน์ ดังนั้นชนิดของ Domain Event ต่างๆ ของเราก็มักจะ implement interface นี้
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"]
ใส่พรอเ port ี้อะไรลงไปบ้าง
หัวข้อที่มีชื่อว่า “ใส่พรอเ port ี้อะไรลงไปบ้าง”ตัวที่บอกเล่าเหตุการณ์ได้ครบถ้วนคือ “การผสมระหว่างชื่อของ 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 ตัวหลังไปแล้ว — กรณีนี้ลำดับเหตุผลมีผลกระทบที่ “ละทิ้งได้”
ไม่ใช่ทุก Event ที่เกิดจาก Command
หัวข้อที่มีชื่อว่า “ไม่ใช่ทุก Event ที่เกิดจาก Command”มีอีกประเด็นเกี่ยวกับสิ่งที่อาจเป็นเหตุให้เกิด 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 หนึ่งหรือหลายตัวเพื่อขอให้ระบบทำชุดของการกระทำต่อไป
เมื่อลูกค้ายืนยันคำสั่งซื้อ 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 Sourcing
หัวข้อที่มีชื่อว่า “Event Sourcing”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 — 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 เป็นเครื่องมือทรงพลัง แต่ “แทบจะต้องใช้ 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:
- 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 ควรอยู่ในรูปแบบใด?