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

Domain Events

นัก​ปฏิบัติ DDD ค้น​พบ​ว่า การ​จะ​เข้าใจ problem domain ได้​ลึก​ขึ้น เรา​ไม่​ควร​มอง​แค่ “สิ่งของ” (entity) เท่านั้น แต่​ต้อง​มอง​ด้วย​ว่า มี “เหตุการณ์” อะไร​เกิด​ขึ้น​บ้าง​ใน domain เหตุการณ์​เหล่า​นี้​คือ Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design — สิ่ง​ที่​เกิด​ขึ้น​แล้ว​และ​ธุรกิจ​ให้​ความ​สำคัญ มัน​มัก​ถูก​ค้น​พบ​ใน​ช่วง knowledge crunching ร่วม​กับ domain expert และ​มี​คุณค่า​มาก​จน​เกิด​เทคนิคเวิร์กช็อปอย่าง EventStormingEventStormingเวิร์กช็อป​แบบ​ร่วมมือ (Alberto Brandolini, 2013) ใช้​กระดาษ​โน้ต​สี​ติด​บน​ผนัง: ส้ม=event, น้ำเงิน=command, เหลือง=aggregate ฯลฯ เพื่อ​ค้น​พบ model และ​ขอบเขต​ของ contextProcess ที่​มุ่ง​สำรวจ event โดยตรง

แต่​เมื่อ model เชิง​แนวคิด​กลาย​เป็น “event-centric” code ก็​ต้อง​เป็น event-centric ตาม​ไป​ด้วย เพื่อ​สะท้อน model นั้น และ​นี่​คือ​จุด​ที่ domain events design pattern เข้า​มา​เพิ่ม​คุณค่า — บท​นี้​ว่าด้วย​การนำ domain event มา​แสดงออก​ใน code ทาง​เลือก​ใน​การ implement และ trade-off ของ​แต่ละ​แบบ

ระวัง​คำ​กำกวม: เหตุการณ์​จริง vs. design pattern

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

Domain Event ไม่​ได้​บังคับ​ให้​ใช้ Event Sourcing

เข้าใจ​ผิด​กัน​บ่อย​ว่า​ใช้ domain event แล้ว​ต้อง​ใช้ Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture — ซึ่ง​ไม่​จริง คุณ​ยัง​ใช้​ฐาน​ข้อมูล SQL แบบ​เดิม​ได้​ตาม​ปกติ แม้ว่า domain event จะ​จับ​คู่​เข้า​กับ event sourcing หรือ asynchronous messaging ได้​ลงตัว​ก็ตาม (รายละเอียด​อยู่​ใน​บท Event Sourcing)


ถ้า​คุณ​รู้จัก publish/subscribe หรือ event ใน​ภาษา​อย่าง C# คุณ​จะ​เข้าใจ pattern นี้​ได้​เร็ว​มาก หัวใจ​คือ: เรา​ใช้​คอมโพเนนต์ตัวกลาง (บาง​ครั้ง​เรียก​จาก​ภายใน domain model เอง) เพื่อ publish event ออก​ไป จาก​นั้น event handler ทุก​ตัว​ที่​ลง​ทะเบียน​กับ event ชนิด​นั้น​จะ​ถูก​เรียก​ทำงาน โดย​ค่า​เริ่มต้น​จะ​ทำงาน​แบบ synchronous อยู่​บน thread เดียวกัน — แต่​จะ​ทำ​แบบ asynchronous ก็ได้​เช่น​กัน

ตัว event เอง​เป็น​แค่ class ธรรมดา​ที่ เปลี่ยนแปลง​ไม่​ได้ (immutableImmutableเปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง ถ้า​จะ “เปลี่ยน​ค่า” ต้อง​สร้าง​อ็อบเจ็กต์ใหม่​แทน หลักการ​สำคัญ​ของ Value Object เพื่อ​เลี่ยง bug จาก​การ​ใช้​อ้างอิง​ร่วม​กันTactical Design) มี property แบบ public เป็น data object (POCO/POJO) ที่​แทน​เหตุการณ์​สำคัญ​ใน domain ตั้ง​ชื่อ​เป็น อดีตกาล เสมอ เพราะ​มัน​คือ​สิ่ง​ที่ “เกิด​ไป​แล้ว”

DeliveryGuaranteeFailed.ts
// event ภายในที่เกิดเมื่อจัดส่งช้ากว่าที่รับประกันไว้
// ภายใน bounded context เดียว แนบ domain object ทั้งก้อนได้เลย
class DeliveryGuaranteeFailed {
constructor(public readonly order: OrderForDelivery) {}
}

เมื่อ event ถูก raise ขึ้น event handler ที่​ลง​ทะเบียน​ไว้​จะ​ถูก​เรียก handler เป็น​ได้​ทั้ง class หรือ callback/lambda แบบ anonymous ก็ได้ — ตัวอย่าง​เช่น ลง​ทะเบียน method onDeliveryFailure ให้​ถูก​เรียก​เมื่อ DeliveryGuaranteeFailed ถูก raise

ลง​ทะเบียน handler
function confirm(deliveredAt: Date, orderId: Guid) {
// ลงทะเบียน handler ชั่วคราวเฉพาะช่วง use case นี้
using(DomainEvents.register(DeliveryGuaranteeFailed, onDeliveryFailure), () => {
const order = orderRepository.findBy(orderId);
order.confirmReceipt(deliveredAt);
});
}
หัวใจ​ของ pattern นี้

domain events คือ​การ​เพิ่ม ความหมาย​เชิง domain ลงบน publish/subscribe ทั่วไป — แยก “สิ่ง​ที่​เกิด​ขึ้น” (event) ออก​จาก “สิ่ง​ที่​ต้อง​ทำ​ต่อ” (handler) อย่าง​เด็ดขาด เพื่อ​ให้ domain model เปล่ง​เสียง​เหตุการณ์​จริง​ของ​ธุรกิจ​ออก​มา​ได้​ชัด โดย​ไม่​ผูก​ตัวเอง​เข้า​กับ side effect เชิง​เทคนิค


domain events เข้า​กัน​ได้​ดี​กับ workflow แบบ asynchronous รวม​ถึง​การ​สื่อสาร​ข้าม bounded context (อย่าง​ที่​เรา​เห็น​ใน​เรื่อง integration ของ Part II) คุณ​สามารถ trigger กระบวนการ​เหล่า​นี้​จาก​ภายใน handler ได้ — บาง​ครั้ง​คุณ​อาจ​ต้องการ asynchronous communication ที่​เชื่อถือ​ได้​แม้แต่​ภายใน context เดียว เช่น​เวลา​ทำ aggregate ที่​เป็น eventually consistentEventual Consistencyความ​ถูกต้อง​ที่ “ตาม​มา​ทีหลัง” ไม่ใช่​ทันที ใช้​กับ​การ​อัปเดต​ข้าม Aggregate ผ่าน Domain Event แทน​การ​บังคับ​ให้​ตรง​กัน​ใน​ทันที​ภายใน transaction เดียวTactical Design

แต่​จุด​ที่​ต้อง​ระวัง​ที่สุด​คือ ขอบเขต​ของ transaction: ถ้า handler ตัว​หนึ่ง​อัปเดต​ฐาน​ข้อมูล และ​อีก​ตัว​ส่ง​ข้อความ​เข้า queue คุณ​ต้องการ​ให้​ทั้ง​คู่ rollback พร้อม​กัน หาก​ตัว​ใด​ตัว​หนึ่ง​ล้มเหลว มิ​ฉะนั้น​ข้อมูล​จะ​ไม่​สอดคล้อง​กัน

รูปที่ 18-1: domain model raise domain event หนึ่งตัว ซึ่งกระจายไปยัง handler หลายตัวภายใน transaction boundary เดียว — handler บางตัวเขียน database บางตัวเขียน queue และต้อง commit/rollback ร่วมกันทั้งหมด

อย่า​ให้ side effect แตก​ออก​จาก​กัน

ถ้า handler สอง​ตัว​ต้อง​สำเร็จ​หรือ​ล้มเหลว “พร้อม​กัน​แบบ atomic” คุณ​ต้อง​บังคับ​ให้​มัน​ทำงาน​แบบ synchronous การ​รีบ publish แบบ asynchronous โดย​ไม่​คำนึง​ถึง​ขอบเขต​ของ transaction จะ​เปิด​ช่อง​ให้​เกิด bug จาก​ความ​ไม่​สอดคล้อง​ของ​ข้อมูล (data inconsistency)


นี่​คือ​เส้น​แบ่ง​สำคัญ​ที่​ถ้า​พลาด​จะ​นำ​ไป​สู่​การ implement ที่​แย่​ได้

  • Internal event อยู่ “ภายใน” domain model เดียว ไม่ share ข้าม bounded context เพราะ​มัน​ถูก​จำกัด​ขอบเขต จึง แนบ domain object ทั้ง​ก้อน​ไป​กับ​มัน​ได้ (อย่าง​ใน​ตัวอย่าง​แรก) ไม่​เสี่ยง​อะไร เพราะ context อื่น​ไม่มี​ทาง​มา​ผูก​ติด​กับ object เหล่า​นั้น และ​ถ้า​คุณ​แก้ event แบบ breaking change code จะ compile ไม่​ผ่าน​ทันที (ใน​ภาษา​ที่ compile) จึง ไม่​ต้อง​ทำ versioning
  • External event สื่อสาร​ข้าม context — นี่​คือ Integration EventIntegration Eventevent ที่​สื่อสาร “ข้าม” Bounded Context หรือ​ข้าม microservice ผ่าน event bus — ต่าง​จาก Domain Event ที่​ทำงาน​ภายใน context เดียว (in-process)Architecture ที่​เรา​พูด​ถึง​ใน Part II มัน​ต้อง แบน​ราบ (flat) เปิดเผย​แค่ property ไม่​กี่​ตัว ส่วน​มาก​เป็น​เพียง correlation id และ​ต้อง​ทำ versioning เพื่อ​เลี่ยง breaking change
ExternalEvent.ts
// external event เปิดเผยได้แค่ id ห้ามแนบ domain object ทั้งก้อน
class DeliveryGuaranteeFailed {
constructor(public readonly orderId: Guid) {}
}

ใน use case หนึ่งๆ มัก​มี internal event เกิด​ขึ้น​หลาย​ตัว แต่​มี external event แค่​หนึ่ง​หรือ​สอง​ตัว​ที่​ถูก raise โดย service layer ดัง​รูป​ที่ 18-2 — จึง​ควร​แยก event ทั้ง​สอง​ชนิด​ไว้​คนละ namespace ให้​ชัด

รูปที่ 18-2: ลำดับการไหลใน use case ทั่วไป — domain model raise domain event → internal handler ประมวลผล → service layer publish external event ออกไปให้ consumer context ผ่าน messaging หรือ HTTP endpoint

🚢 ตัวอย่าง​การ​ขนส่ง — CargoWasRouted

ใน​ระบบ​ขนส่ง เมื่อ​วาง ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design ใหม่​ให้ Cargo สำเร็จ Aggregate จะ raise internal event ชื่อ CargoWasRouted ภายใน Booking context — handler ภายใน​อาจ​ไป​อัปเดต​สถานะ​การ​ติดตาม ส่วน service layer จะ​แปลง​มัน​เป็น external event แบนๆ ที่​มี​แค่ trackingId แล้ว publish ออก​ไป​ให้ context อื่น (เช่น การ​แจ้ง​เตือน​ลูกค้า) นี่​คือ internal vs external ใน​รูป​ที่ 18-2 พอดี


ถึง​หน้าตา​จะ​เหมือน​กัน แต่​ความ​รับผิดชอบ​ต่าง​กัน​มาก ต้อง​แยก​ให้​ออก

handler ที่​อยู่​ใน domain model จัดการ event ที่​เกิด​ใน​นั้น มัน​จำลอง “ลำดับ​การ​โต้ตอบ” ที่​เกิด​ใน problem domain จริงๆ เช่น ตัว validator ตรวจสอบ​คำ​สั่ง​ซื้อ​ว่า​ลูกค้า​ไม่​ติด blacklist จาก​การ​ค้าง​ชำระ​ครั้ง​ก่อน แล้ว raise event ของ​ตัวเอง​เพื่อ trigger ขั้น​ถัด​ไป​ของ​กระบวนการ​ธุรกิจ (business process) — เป็น​เรื่อง​ปกติ​ที่ domain event handler จะ delegate ไป​ยัง Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design

handler อีก​กลุ่ม​อยู่​ที่ service layerApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design มัน​ทำงาน​เชิง infrastructure เช่น ส่ง​อีเมล หรือ​คุย​กับ external service อย่าง payment gateway ไม่ใช่​ส่วน​หนึ่ง​ของ UL หรือ domain ความ​รับผิดชอบ​สำคัญ​ของ​มัน​คือ​การ trigger การ​สื่อสาร​กับ bounded context ภายนอก (กลาย​เป็น external event)

อย่า​ให้ infrastructure รั่ว​เข้า domain model

handler ที่​ทำงาน​เชิง​เทคนิค (คุย​กับ database, ส่ง​อีเมล) ควร​ลง​ทะเบียน​ไว้​ที่ service layer เพื่อ​กัน domain model ให้​สะอาด หรือ​ถ้า​จำเป็น ให้​ประกาศ​เป็น contract ของ Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design แล้วไป implement จริง​ที่ service layer


domain events เป็น pattern ที่​ยืดหยุ่น​มาก — มี​หลาย​วิธี implement ตั้งแต่​ใช้​ของ​พื้นฐาน​ใน​ภาษา ไป​จนถึง message bus

วิธี​เริ่มต้น​ที่​ง่าย​ที่สุด​คือ​พึ่ง native feature ของ​ภาษา ลงทุน​น้อย แต่​มี ราคา​ที่​ต้อง​จ่าย: เกิด tight coupling ระหว่าง​ผู้ publish กับ​ผู้ subscribe — handler ต้อง​รู้​ว่า object ตัว​ไหน​เป็น​คน publish event

raise event แบบ native (เทียบ C# event)
class OrderForDelivery {
// ผู้ subscribe ต้องผูกกับ entity ตัวนี้โดยตรง = coupling แน่น
deliveryGuaranteeFailed = new EventEmitter<DeliveryGuaranteeFailed>();
confirmReceipt(deliveredAt: Date) {
if (this.status === OrderStep.Delivered) return;
this.deliveredAt = deliveredAt;
this.status = OrderStep.Delivered;
if (this.offer.isNotSatisfiedBy(this.placedAt, deliveredAt)) {
// ใน C# ต้องเช็ก null ก่อน raise เพราะ event ที่ไม่มี subscriber จะเป็น null
this.deliveryGuaranteeFailed.raise(new DeliveryGuaranteeFailed(this));
}
}
}

เพื่อ คลาย coupling ให้​ใช้ in-memory message bus แทน — publish event ผ่าน bus และ​ลง​ทะเบียน handler ผ่าน bus ผู้ subscribe ไม่​จำเป็น​ต้อง​รู้​ว่า​ใคร​เป็น​ผู้ publish (และ​กลับ​กัน) บาง bus รองรับ​ทั้ง synchronous และ asynchronous ทำให้​ปรับ​บาง​ส่วน​เป็น async ได้​ง่าย​เมื่อ​จำเป็น

ข้อ​ยุ่งยาก​คือ​ต้อง “ส่ง bus” ไป​ทั่ว domain model ซึ่ง​เป็น concern เชิง​เทคนิค​ที่​ใน​อุดมคติ​เรา​ไม่​อยาก​ให้​มัน​มารบกวน domain — แต่​บาง​ทีม​ก็​ยอมรับ trade-off นี้ เพราะ​มัน​ทำให้ model สื่อ​ความหมาย​เชิง domain ได้​ดี​ขึ้น​โดย​รวม

อีก version ยอด​นิยม​คือ static class ชื่อ DomainEvents ที่​อยู่​ใน domain model และ​จัดการ concern เชิง​เทคนิค​ให้ มี2 method สำคัญ​คือ Register<T> และ Raise<T> — กระชับ​กว่า in-memory bus มาก

DomainEvents (static) — แนว​ของ Udi Dahan
class DomainEvents {
// ปัญหาใหญ่คือ threading: ทุก thread ใช้ instance เดียวกัน
private static actions: Delegate[] = [];
static register<T>(callback: (e: T) => void): Disposable {
this.actions.push(callback);
// คืน Disposable เพื่อถอด handler อัตโนมัติหลังใช้เสร็จ
return { dispose: () => remove(this.actions, callback) };
}
static raise<T>(event: T): void {
for (const action of this.actions) action(event);
}
}
ปัญหา threading ของ static class

เพราะ DomainEvents เป็น static ทุก thread จึง​ใช้ collection เดียวกัน — handler ที่​ลง​ทะเบียน​ใน thread หนึ่ง​จะ​ถูก​เรียก​เมื่อ thread อื่น raise event ด้วย ใน C# มี​เทคนิค​ใช้ [ThreadStatic] ให้​แต่ละ thread มี collection ของ​ตัวเอง แต่ Scott Hanselman เตือน​ว่า​ห้าม​ใช้ ThreadStatic ใน app ASP.NET เด็ดขาด

ถ้า​ไม่​อยาก​ผูก domain เข้า​กับ static publisher (ซึ่ง​นัก​ปฏิบัติ DDD หลาย​คน​ก็​ไม่​อยาก) ให้ ส่ง dispatcher เข้าไป​เป็น argument ของ method ใน domain แทน — แลก​มา​กับ signature ของ method ที่​ปน​เปื้อน parameter เชิง​เทคนิค จึง​ควร​ใช้​อย่าง​ระมัดระวัง

method injection
confirmReceipt(deliveredAt: Date, dispatcher: IEventDispatcher) {
// ...
if (this.offer.isNotSatisfiedBy(this.placedAt, deliveredAt)) {
dispatcher.raise(new DeliveryGuaranteeFailed(this));
}
}

แนว​ที่​นิยม​ที่สุด​แนว​หนึ่ง​คือ แยก​การ publish ออก​จาก​การ handle เพื่อ​กัน side effect ให้​เป็น​อิสระ ทำได้​โดย​เก็บ collection ของ event ไว้​บน Aggregate RootAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น “ประตู​เดียว” เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design (เช่น property recordedEvents) แล้ว​ให้ service layer เป็น​คน​เรียก dispatcher มา publish หลัง aggregate ทำงาน​เสร็จ — เก็บ concern เชิง​เทคนิค​ไว้​นอก domain model collection นี้​จะ ว่าง​เสมอ ตอน​โหลด​จาก persistence มัน​บรรจุ​เฉพาะ event ที่​เกิด​ใน transaction ปัจจุบัน

เก็บ event บน entity แล้ว dispatch ที่ service layer
class OrderForDelivery {
// event ที่เกิดใน transaction นี้เท่านั้น
recordedEvents: object[] = [];
confirmReceipt(deliveredAt: Date) {
// ...แทนที่จะ publish ทันที ให้บันทึกไว้ก่อน
this.recordedEvents.push(new DeliveryGuaranteeFailed(this));
}
}
// service layer ดึง event ที่ถูกบันทึกไว้ไป dispatch หลัง use case จบ
function confirm(deliveredAt: Date, orderId: Guid) {
const order = orderRepository.findBy(orderId);
order.confirmReceipt(deliveredAt);
for (const evnt of order.recordedEvents) dispatcher.dispatch(evnt);
}

ข้อดี​คือ side effect จะ​ไม่ “แทรก” เข้า​มากลางคัน​ระหว่าง​ที่ method กำลัง​ทำงาน — มัน​จะ​เกิด​ขึ้น​ทีหลัง เมื่อ control ไหล​กลับ​มา​ที่ application service แล้ว​เท่านั้น

นี่​คือ​รากฐาน​ของ Event Sourcing

การ​เก็บ event ไว้​บน aggregate root เป็น​พื้นฐาน​สำคัญ​ของ​การ​สร้าง event-sourced domain model — ถ้า​คุณ​กำลัง​พิจารณา Event SourcingEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture แนว​นี้​เกี่ยวข้อง​โดยตรง คุณ​สามารถ​ใช้ IoC container เป็น dispatcher ได้​ด้วย (เช่น สแกน assembly แล้ว​ลง​ทะเบียน handler อัตโนมัติ​ตาม convention ผ่าน interface IHandles<T>)

🛒 ลอง​คิด​คู่​ขนาน — e-commerce

ลอง​นึกถึง Order ใน e-commerce: เมื่อ​ลูกค้า​ชำระ​เงิน​สำเร็จ Aggregate จะ recordedEvents.push(new OrderWasPaid(...)) แทนที่​จะ​ยิง​อีเมล​ทันที จาก​นั้น application service จะ dispatch event นี้​ออก​ไป — handler ใน domain อาจ delegate ไป Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design เพื่อ​จอง​สต็อก ส่วน handler ใน service layer ส่ง​อีเมล​ใบเสร็จ​และ publish external event ให้​ระบบ​จัด​ส่ง การ “เก็บ​แล้ว​ค่อย dispatch” ทำให้​กฎ​ธุรกิจ (จอง​สต็อก) แยก​จาก side effect เชิง​เทคนิค (ส่ง​อีเมล) อย่าง​สะอาด


การ test code ที่​ใช้ domain events ไม่​ได้​ยาก​กว่า code OO ทั่วไป บาง​ครั้ง​ง่ายกว่าด้วย​ซ้ำ เพราะ​ไม่​ต้อง mock collaborator แต่​รูปแบบ test ต่าง​กัน​เล็กน้อย

  • Unit test (แบบ publish): ลง​ทะเบียน callback ที่​ตั้ง flag ไว้ แล้ว​เรียก domain behavior ถ้า flag เป็น true ตอน​จบ แปล​ว่า event ถูก raise จริง — การ​มี static DomainEvents ไม่​ได้​ทำให้ test ยาก เพราะ​เรา​ไม่​ต้อง mock มัน
  • Unit test (แบบ recorded): เรียก behavior แล้ว assert ว่า event ถูก บันทึก ลง recordedEvents (ไม่ใช่​ถูก publish)
  • Application service test: test ระดับ service layer แทบ​ไม่​ต่าง​จาก​ตอน​ไม่​ได้​ใช้ domain events เลย เพราะ event และ handler เป็น​แค่ implementation detail — เซ็ตอัป service เรียก use case แล้ว assert ว่า external event ที่​คาด​หวัง​ถูก publish ออก​ไป
unit test: ตรวจ​ว่า event ถูก raise
it("raise DeliveryGuaranteeFailed เมื่อส่งช้ากว่ารับประกัน", () => {
let eventWasRaised = false;
offer.stub(isNotSatisfiedBy).returns(true);
const order = new OrderForDelivery(/* ... */, offer);
using(DomainEvents.register(DeliveryGuaranteeFailed, () => { eventWasRaised = true; }), () => {
order.confirmReceipt(timePizzaDelivered);
});
expect(eventWasRaised).toBe(true);
});
ข้อดี​แฝง​ของ​การ test ที่ service layer

เพราะ test ระดับ application service ไม่มี​ร่องรอย​ของ domain events pattern เลย คุณ​จึง refactor domain model ให้​หัน​มา​ใช้ domain events ได้​โดย​ไม่​ต้อง​แตะ test service layer แม้แต่​บรรทัด​เดียว — ซ้ำ test ชุด​เดิม​ยัง​ทำ​หน้าที่​เป็น​ตาข่าย​นิรภัย คอย​ยืนยัน​ว่าการ refactor ไม่​ได้​ทำ​พฤติกรรม​เดิม​พัง


  • domain event คือ​เหตุการณ์​สำคัญ​ใน​โลก​จริง​ของ problem domain และ​เป็น​ส่วน​หนึ่ง​ของ Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design
  • domain events ยัง​เป็น design pattern ที่​ทำให้​เหตุการณ์​เหล่า​นั้น​ปรากฏ​ชัดเจน​ใน code ใน​ลักษณะ​เดียว​กับ publish/subscribe
  • ต่าง​จาก messaging ข้าม bounded context — domain events ทำงาน ภายใน domain model เดียว และ​มัก​เป็น synchronous
  • มัน​ช่วย​ให้​ค่อยๆ ย้าย​บาง use case ไป​เป็น asynchronous ได้​อย่าง​ราบรื่น​เมื่อ​ถึง​เวลา​จำเป็น
  • handler บาง​ตัว​อยู่​ใน domain (เรียก domain logic) บาง​ตัว​อยู่​ที่ service layer (งาน​เชิง infrastructure)
  • มี​หลาย​วิธี implement: native event (coupling แน่น), in-memory bus (coupling หลวม), static DomainEvents, method injection, และ​การ​เก็บ event ไว้​บน aggregate root
  • การ​ใช้ domain events ไม่​ได้​บังคับ ให้​ใช้ event sourcing แม้​หลาย​คน​จะ​พบ​ว่า​จับ​คู่​กัน​แล้ว​ได้​ผล​ดี
  • สุดท้าย​แล้ว pattern นี้​คือ​เรื่อง​ของ trade-off — แลก​การ​เพิ่ม indirection หนึ่ง​ชั้น​และ​การกระจาย code ไป​หลาย file กับ domain model ที่​ห่อ​หุ้ม​สมบูรณ์​และ​ขับ​เน้น​เหตุการณ์​จริง​ของ domain
flowchart LR
  A["Domain Model<br/>(Aggregate)"] -->|raise| B["Domain Event<br/>(immutable, อดีตกาล)"]
  B --> C["Internal Handler<br/>(domain logic)"]
  B --> D["Service-layer Handler<br/>(infrastructure)"]
  D -->|publish| E["External Event<br/>(Integration Event)"]
  E -.->|messaging / HTTP| F["Consumer Context"]

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

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

  • Domain Events — บท​อ้างอิง​ตรง​ตัว​ของ design pattern ที่​บท​นี้​อธิบาย​ทั้งหมด ตั้งแต่​แนวคิด publish/subscribe ไป​จนถึง​ทาง​เลือก​การ implement
  • Event Aggregator — pattern ที่​อยู่​เบื้องหลัง in-memory bus และ static DomainEvents class ที่​ใช้​คลาย coupling ระหว่าง​ผู้ publish กับ​ผู้ subscribe
  • Aggregate — ฐาน​ของ​แนวทาง “เก็บ event บน aggregate root แล้ว​ค่อย dispatch” ที่​บท​นี้​แนะนำ​ว่า​เป็น​แนว​ที่​นิยม​ที่สุด
  • Event Storming — เทคนิคเวิร์กช็อป​ที่​ใช้​ค้น​พบ domain event ตั้งแต่​ต้น ก่อน​ที่​มัน​จะ​ถูก​นำ​มา​แสดงออก​เป็น code ตาม​ที่​บท​นี้​กล่าว​ถึง

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

ข้อ 1 / 4

ข้อใดอธิบาย 'domain events pattern' ได้ถูกต้องที่สุด?