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

เชื่อม Bounded Context ด้วย Messaging

บท​ที่​แล้ว​เรา​คุย​กัน​ถึง “ทำไม” เรา​จึง​ต้อง​แยก​ระบบ​ออก​เป็น​ชิ้น​เล็กๆ ที่​เป็น​อิสระ​ต่อ​กัน ส่วน​บท​นี้​จะ​ลงมือ​ทำ​ใน​เรื่อง​ของ “อย่างไร” — เรา​จะ​เชื่อม Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design เข้าหา​กัน​ด้วย asynchronous messaging แทน​การ​เรียกตรงๆ แบบ HTTP/RPC ที่​ทุก​ฝ่าย​ต้อง​ออนไลน์​พร้อม​กัน

หัวใจ​ของ​บท​นี้​คือ​การ นำ event ที่​เกิด​ขึ้น​จริง​ใน​กระบวนการ​ธุรกิจ​มา​ทำให้​เป็น message ใน​ระบบ ทำให้ code พูด​ภาษา​เดียว​กับ​ธุรกิจ พร้อม​กับ​ได้​ของ​แถม​เป็น​ความ​ทนทาน​ต่อ​ความ​ล้มเหลว (fault tolerance) และ​ความ​สามารถ​ใน​การ​ขยาย​ตัว (scalability) — แต่​ก็​แลก​มา​ด้วย​ความ​ท้า​ทายใหม่ๆ เช่น Eventual ConsistencyEventual Consistencyความ​ถูกต้อง​ที่ “ตาม​มา​ทีหลัง” ไม่ใช่​ทันที ใช้​กับ​การ​อัปเดต​ข้าม Aggregate ผ่าน Domain Event แทน​การ​บังคับ​ให้​ตรง​กัน​ใน​ทันที​ภายใน transaction เดียวTactical Design ที่​เรา​ต้อง​เรียนรู้​ที่​จะ​อยู่​ร่วม​กับ​มัน​ให้​ได้


ระบบ​ที่​สื่อสาร​ผ่าน message นั้น​ต่าง​จาก​ระบบ OO แบบ​ดั้งเดิม​ที่​ทำงาน​อยู่​ใน process เดียวกัน​อย่าง​สิ้นเชิง มัน​เปิด​ประ​โย​ชน์ใหม่ๆ ให้​เรา แต่​ก็​เรียกร้อง​วิธี​คิด​แบบ asynchronous ที่​ต้อง​ปรับ mindset พอสมควร เรา​จะ​เริ่ม​จาก​ชิ้น​ส่วน​ที่​เป็น​กาว​ยึด​ทุก​อย่าง​เข้า​ด้วย​กัน — message bus

ถ้า​มี​คอมโพเนนต์ส่วนกลาง​เพียง​ตัว​เดียว​รับผิดชอบ​ส่ง​ต่อ message ทั้งหมด ทั้ง​ระบบ​จะ​ล่ม​ทันที​เมื่อ​มัน​พัง และ​มัน​ยัง​กลาย​เป็น​คอ​ขวด​ที่​ทำให้​ขยาย​แต่ละ​ส่วน​ได้​ยาก​อีก​ด้วย message bus แก้​ปัญหา​นี้​ด้วย​การ​เป็น ระบบ​กระจาย (distributed) ที่​มี “agent” เล็กๆ รัน​อยู่​ใน​ทุก​คอมโพเนนต์ที่​ต้อง​ส่ง​หรือ​รับ message จึง​ไม่มี​จุด​ล้มเหลว​จุด​เดียว (single point of failure)

Message Bus กับ Message Broker

ถ้า​ให้​คอมโพเนนต์ส่วนกลาง​ตัว​เดียว​รับ​และ​ส่ง message ทั้งหมด เรา​จะ​เรียก​มัน​ว่า broker ใน​อดีต broker มัก​ถูก​มอง​ข้าม​เพราะ​กังวล​เรื่อง​การ​ล่ม​และ​การ​ขยาย​ตัว แต่ broker สมัย​ใหม่​อย่าง Apache Kafka สามารถ​ขยาย​แบบ horizontal ได้​และ​รองรับ feature ของ bus ได้​เกือบ​ครบ แนวคิด​ใน​บท​นี้​จึง​นำ​ไป​ใช้​กับ​ระบบ​ที่​ใช้ broker สมัย​ใหม่​ได้​เช่น​กัน

การ​ที่ message “หาย​ไป” กลาง​ทาง​อาจ​มี​ต้นทุน​สูง​มาก — ลูกค้า​ย่อม​ไม่​พอใจ​แน่​หาก​ถูก​ตัด​บัตรเครดิต​แล้ว​ไม่​ได้​รับ​สินค้า เพียง​เพราะ message สั่ง​จัด​ส่ง​หาย​ไป​ระหว่าง​ทาง น่า​เสียดาย​ที่​แทบ​เป็น​ไป​ไม่​ได้ที่​จะ​รับประกัน​การ “ส่ง​ถึง​เพียง​ครั้ง​เดียว​เป๊ะๆ” (exactly-once) เพราะ​หาก​ส่ง​ไป​แล้ว​ไม่​ได้​รับ acknowledgement กลับ​มา เรา​ก็​ต้อง​ส่ง​ซ้ำ — แต่​จะ​รู้​ได้​อย่างไร​ว่า​ฝั่ง​ปลายทาง​รับ​ไป​แล้ว เพียง​แต่ ack หาย​ระหว่าง​ทาง​เท่านั้น?

ใน​ทาง​ปฏิบัติ แนวทาง​ที่​นิยม​ที่สุด​คือ at-least-once delivery (ส่ง​จนกว่า​จะ​ถึง​ปลายทาง​อย่าง​น้อยหนึ่ง​ครั้ง) ซึ่ง​มี​ความ​เสี่ยง​ว่า message อาจ​ถูก​ประมวล​ผล​ซ้ำ เรา​จึง​ต้อง​จับ​คู่​มัน​เข้า​กับ idempotent message

At-least-once + Idempotent

Idempotent message คือ message ที่​ส่ง​กี่​ครั้ง​ก็​ให้​ผลลัพธ์​เหมือน​ส่ง​เพียง​ครั้ง​เดียว เช่น message สั่ง​ตัด​เงิน ถ้า​มา​ถึง​สอง​ครั้ง ฝั่ง​รับ​จะ​ประมวล​ผล​แค่​ครั้ง​แรก​เท่านั้น มัก​ทำได้​ด้วย​ตัว​ระบุ​ที่​ไม่​ซ้ำ​กัน (เช่น payment reference) — การ​จับ​คู่ at-least-once เข้า​กับ idempotency คือ​สูตร​สำเร็จ​ที่​ทำให้​ระบบ​ทั้ง​เชื่อถือ​ได้​และ​ไม่​ตัด​เงิน​ลูกค้า​ซ้ำ

กลไก​เบื้องหลัง​การ​ส่ง​ซ้ำ​คือ pattern ที่​ชื่อ store-and-forward: ก่อน​ส่ง message เรา​จะ เก็บ​มัน​ลง queue ไว้​ก่อน ถ้า​ส่ง​ถึง​และ​ได้​รับ ack สำเร็จ ก็​ลบ​สำเนา​ทิ้ง แต่​ถ้า​ส่ง​ไม่​ถึง ก็​ลอง​ใหม่ — messaging framework ส่วน​ใหญ่​จัดการ​ความ​ซับซ้อน​นี้​ให้​เรา โดย​เปิด​ให้​เรา​ตั้ง​กฎ​ได้​ว่า​จะ retry กี่​ครั้ง​และ​เว้น​ระยะ​ห่าง​เท่าไร เมื่อ Service A ส่ง​ให้ Service B, message จะ​ไป​รอ​อยู่​บน queue ของ B; หาก B ประมวล​ผล​ไม่​สำเร็จ มัน​ก็​จะ​ถูก​ใส่​กลับ​เข้า queue เพื่อ​ลอง​ใหม่


นี่​คือ​ความ​แตก​ต่าง​เชิง​แนวคิด​ที่​สำคัญ​ที่สุด​ของ​บท​นี้ message แบ่ง​ออก​เป็น​สอง​ชนิด:

  • Command สั่ง​ให้ “บาง​อย่าง​ต้อง​เกิด​ขึ้น” เช่น PlaceOrder, ChargeCreditCard ผู้​ส่ง​รู้​ว่า​ผู้รับ​ควร​จัดการ​มัน​อย่างไร จึง​เกิด coupling เชิง​ตรรกะ​ระหว่าง​ผู้​ส่ง​กับ​ผู้รับ และ command จะ​ถูก​จัดการ ที่​เดียว​เท่านั้น
  • Event ประกาศ​ว่า “บาง​อย่าง​ได้​เกิด​ขึ้น​แล้ว” เช่น OrderCreated, PaymentAccepted ผู้​ส่ง ไม่รู้​และ​ไม่​สนใจ ว่า​ใคร​จะ​เป็น​ผู้จัดการ​มัน​บ้าง เพราะ event ทำงาน​บน​รูปแบบ publish/subscribe

eventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design มี coupling ที่​หลวม​กว่า​มาก ข้อ​ได้​เปรียบ​สำคัญ​คือ เพิ่ม subscriber ใหม่​ได้​โดย​ไม่​ต้อง​แก้ code เดิม ธุรกิจ​สามารถ​เพิ่ม Bounded Context ใหม่​ทั้ง​ก้อน​เข้า​มา​ใน​กระบวนการ​ได้ เพียง​แค่ subscribe กับ event ที่​มี​อยู่​แล้ว

รูปที่ 12-2: การเพิ่ม Marketing ให้ subscribe กับ Order Created Event ไม่กระทบ code ของ Billing/Shipping ที่ subscribe อยู่ก่อนแล้ว

ตั้ง​ชื่อ Command vs Event ให้​ถูก​กาล

ธรรมเนียม​ที่​นิยม​ที่สุด​คือ ตั้ง​ชื่อ command เป็น​ประโยค​คำ​สั่ง (PlaceOrder, ChangeAddress, RefundAccount) และ​ตั้ง​ชื่อ event เป็น​อดีตกาล เพื่อ​บอก​สิ่ง​ที่​เกิด​ขึ้น​ไป​แล้ว (OrderCreated, MovedAddress, AccountRefunded) — กฎ​ง่ายๆ ข้อ​นี้​ทำให้​ทั้ง​ทีมอ่านโฟลว์ของ​ระบบ​ออก​ได้​ใน​ทันที

🚢 Cargo: command กับ event ใน​ระบบ​ขนส่ง

ใน​ระบบ​ขนส่ง​ตู้ container การ​ที่​ลูกค้า​กด​จอง​คือ BookCargo (command — ส่ง​ไป​ยัง​ที่​เดียว​คือ Booking context) เมื่อ​จอง​สำเร็จ ระบบ​จะ publish CargoWasBooked (event) ออก​มา จาก​นั้น context อื่นๆ ที่​สนใจ​ก็ subscribe ได้​ตาม​ต้องการ: ฝ่าย​วางแผน​เส้นทาง​รับ​ไป​จัด ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design, ฝ่าย​แจ้ง​เตือน​ส่ง​อีเมล​ยืนยัน และ​ใน​ภายหลัง​หาก​อยาก​เพิ่ม feature “สะสม​แต้ม​ลูกค้า” ก็​เพียง​แค่​เพิ่ม context ใหม่​เข้า​มา subscribe CargoWasBooked โดย​ไม่​ต้อง​แตะ​ต้อง Booking เลย


ก่อน​ลงมือ​เขียน code การ​วาด​ภาพ​สิ่ง​ที่​กำลัง​จะ​สร้าง​นั้น​ช่วย​ได้​มาก โดย​เฉพาะ​เมื่อ​แนวคิด​ยัง​ใหม่ การ​ออกแบบ​ระบบ messaging มัก​เริ่ม​จาก​สาม​ขั้นตอน แต่​ขั้น​ที่​สำคัญ​ที่สุด​คือ เริ่ม​จาก domain ก่อน​เสมอ

เมื่อ Bounded Context เชื่อม​กัน​ด้วย​การ share command และ event เรา​จะ​ได้​โอกาส​ทอง​ที่​จะ จับ​คู่​เหตุการณ์​จริง​ใน​กระบวนการ​ธุรกิจ​เข้า​กับ event ใน​ซอฟต์แวร์ วิธีการ​คือ​ไป​นั่ง​กับ domain expert ทำ EventStormingEventStormingเวิร์กช็อป​แบบ​ร่วมมือ (Alberto Brandolini, 2013) ใช้​กระดาษ​โน้ต​สี​ติด​บน​ผนัง: ส้ม=event, น้ำเงิน=command, เหลือง=aggregate ฯลฯ เพื่อ​ค้น​พบ model และ​ขอบเขต​ของ contextProcess เพื่อ​ค้นหา​เหตุการณ์​สำคัญ​ที่​เกิด​ขึ้น​จริง เช่น “สั่ง​ซื้อ​แล้ว”, “รับ​ชำระ​เงิน​แล้ว”, “จัด​ส่ง​แล้ว” — เหตุการณ์​เหล่า​นี้​เกิด​ขึ้น​ใน​ธุรกิจ​อยู่​แล้ว​แม้​จะ​ยัง​ไม่มี​ซอฟต์แวร์ เมื่อ​จับ​มัน​ได้ มัน​จะ​กลาย​เป็น​ส่วน​หนึ่ง​ของ Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design และ​นำ​มา​ประกอบ​กัน​เป็น business use case ได้​อย่าง​ครบถ้วน

component diagram แสดง “การ​ไหล​ของ​ตรรกะ” ระหว่าง​คอมโพเนนต์ ไม่มี​โครงสร้าง​ตายตัว ใช้​เพียง​กล่อง​กับ​เส้น อย่า​ลง​ลึก​ถึง​ระดับ​ชื่อ class/method และ​อย่า​สูง​ถึง​ขั้น​เลือก​เทคโนโลยี — ให้​คง​ไว้​ที่​ระดับ​ของ event และ​กระบวนการ ไดอะแกรม​ชนิด​นี้​เหมาะ​กับ​การ​ร่าง​ร่วม​กับ domain expert ตอน​ทำ knowledge-crunching แนวทาง​ที่​ดี​คือ 1 diagram ต่อ1 high-level use case

รูปที่ 12-3: component diagram ของ use case สั่งซื้อ — Website ส่ง Place Order ไป Sales (1) → Sales publish Order Created (2) → Billing เรียก payment provider (3) → publish Payment Accepted (4) → Shipping จัดส่ง (5) เส้นทึบ=event, เส้นประยาว=command, เส้นจุด=RPC

containers diagram ตอบ​คำถาม​คนละ​ชุด​กัน: ส่วน​ต่างๆ ของ app คุย​กัน​อย่างไร? เรา​มั่นใจ​ได้​อย่างไร​ว่า​ระบบ​ทน fault และ​ขยาย​ตัว​ได้? มัน​แสดง​ให้​เห็น​ว่า​ระบบ​ถูก จัด​กลุ่ม​อย่างไร สื่อสาร​กัน​ด้วย​โพรโทคอล​ใด และ​เลือก​ใช้​เทคโนโลยี​หลัก​อะไร

รูปที่ 12-4: containers diagram ของระบบ e-commerce — แต่ละ Bounded Context เลือกภาษา/ฐานข้อมูลของตัวเองได้, คุยกันภายในด้วย messaging, เว็บดึงข้อมูลผ่าน Ajax HTTP, การคุยกับ payment provider ภายนอกถูกห่อด้วย messaging gateway

ประเด็น​สำคัญ​ที่​อ่าน​ได้​จาก diagram นี้:

  • การ​สื่อสาร ภายใน ระหว่าง Bounded Context ใช้ messaging ทั้งหมด
  • การ​คุย​กับ payment provider ภายนอก​ที่​ไม่​น่า​เชื่อถือ ถูก​ห่อ​ด้วย messaging gateway เพื่อ​เพิ่ม​ความ​เชื่อถือ​ได้
  • แต่ละ context ไม่ share ฐาน​ข้อมูล หรือ dependency กัน — เลือก​เทคโนโลยี​ของ​ตัวเอง​ได้​อิสระ (C#, Ruby on Rails, MS SQL, RavenDB ฯลฯ)
อย่า​ยัด​ทุก​อย่าง​ลง​ใน diagram เดียว

รักษา diagram ให้​กระชับ​ด้วย​การ​คง​ไว้​ที่ ระดับ abstraction เดียวกัน​ทั้ง​ภาพ — แม้​จะ​รวม containers กับ component diagram เป็น​รูป​เดียว​ได้​ก็​จริง แต่​ข้อมูล​ที่​มี​คนละ​วัตถุประสงค์​จะ​ปะปน​กัน​จน​อ่าน​ยาก​ขึ้น Simon Brown (เจ้าของ model C4) จึง​แนะนำ​ให้​แยก​ชั้น​ของ diagram ให้​ชัดเจน

Evolutionary Architecture

เมื่อ​เรียนรู้ domain มาก​ขึ้น​จาก​การ​ทำงาน​ร่วม​กับ domain expert เรา​จะ​ต้อง refactor model และ​ปรับ UL — diagram ก็​ต้อง​วิวัฒน์​ตาม​ไป​ด้วย หาก​มี event ใหม่​เกิด​ขึ้น หรือ​ขอบเขต​ของ context ต้อง​ขยับ ก็​ให้​อัปเดต diagram ตาม​ไป​ด้วย เพื่อ​ให้​มัน​ยัง​คง​สะท้อน​ความ​เป็น​จริง​และ​ช่วย​ให้​ผู้คน​ตัดสิน​ใจ​เชิง​ธุรกิจ​ได้ (แต่​ก็​ต้อง​จัด​ลำดับ​ความ​สำคัญ​ตาม​ความ​ต้องการ​ของ​ธุรกิจ — บาง​ครั้ง​การ​ส่ง feature ออก​ไป​ก่อน​ก็​คุ้ม​ค่า​กว่า​การ​จัด​ขอบเขต​ให้​สมบูรณ์​แบบ)


Messaging Gateway — ห่อ​การ​เรียก​ภายนอก​ที่​ไม่​น่า​เชื่อถือ

หัวข้อ​ที่​มีชื่อ​ว่า “Messaging Gateway — ห่อ​การ​เรียก​ภายนอก​ที่​ไม่​น่า​เชื่อถือ”

ลอง​ดู​ตัวอย่าง code ที่ “อันตราย” กัน​ก่อน เพื่อ​ให้​เห็น​ภาพ​ว่า​ทำไม​เรา​จึง​ต้อง​มี gateway สมมุติ​ว่า handler ของ Billing เขียน​ไว้​แบบ​นี้:

OrderCreatedHandler.ts (version อันตราย)
async function handle(event: OrderCreated) {
const card = await db.getCardDetailsFor(event.userId);
// เรียก provider ภายนอก — ตัดบัตรจริงไปแล้ว!
const conf = await paymentProvider.chargeCreditCard(card, event.amount);
if (conf.isSuccess) {
await db.savePaymentDetails(conf); // ถ้า DB ล่มตรงนี้...
bus.publish(new PaymentAccepted(event.orderId));
}
}

เห็น bug ไหม? ถ้า db.savePaymentDetails() ล้มเหลว (DB ล่ม) message จะ​ถูก​ใส่​กลับ​เข้า queue เพื่อ retry — แต่​เรา​ตัด​บัตร​ลูกค้า​ไป​เรียบร้อย​แล้ว! พอ retry มัน​ก็​จะ​ตัด​ซ้ำ​อีก​ครั้ง​แล้ว​ครั้ง​เล่า นี่​จึง​เป็น​เหตุผล​ที่​เรา​ต้อง​มี messaging gateway

แนวคิด​ของ messaging gateway คือ การ​ผ่า transaction ใหญ่​หนึ่ง​ก้อน​ออก​เป็น​สอง​ท่อน ด้วย​การ​แทรก message คั่น​กลาง: ท่อน​หนึ่ง​ทำ​หน้าที่​คุย​กับ provider ภายนอก ส่วน​อีก​ท่อน​ทำ​หน้าที่​อัปเดต DB เช่น​นี้ หาก​ท่อน​หลัง​ล้มเหลว​และ​ถูก retry มัน​ก็​จะ​ไม่​ทำซ้ำ​สิ่ง​ที่​ทำ​ไป​แล้ว (การ​ตัด​บัตร)

OrderCreatedHandler.ts (ห่อ​ด้วย gateway)
async function handle(event: OrderCreated) {
const card = await db.getCardDetailsFor(event.userId);
const conf = await paymentProvider.chargeCreditCard(card, event.amount);
// ส่ง command ภายในตัวเองเพื่อ "บันทึกผล" แยกเป็นอีกท่อน
// ถ้าท่อนนี้ retry ก็จะไม่ตัดบัตรซ้ำ เพราะตัดไปแล้วในท่อนบน
bus.sendLocal(new RecordPaymentAttempt(event.orderId, conf.status));
}
ทำไม​ใช้ command ไม่ใช่ event

ขั้นตอน “บันทึก​ผล​การ​ตัด​บัตร” เป็น รายละเอียด​เชิง​เทคนิค​ที่​มี​ไว้​เพื่อ fault tolerance ไม่ใช่ domain event เรา​จึง​ไม่​ต้องการ​เปิดเผย​มัน​ให้ context อื่น​รับรู้ — เรา​จึง​ใช้ command (RecordPaymentAttempt) เพื่อ​รับประกัน​ว่า​มัน​จะ​ถูก​จัดการ “ที่​เดียว” ภายใน Billing เอง แล้ว​จึง​ค่อย publish PaymentAccepted ซึ่ง​เป็น event จริง​ของ domain ออก​ไป​ใน​ภายหลัง

เมื่อ provider ล่ม​เพียง​ชั่วคราว (transient error) message bus จะ retry ให้​โดย​อัตโนมัติ และ​หาก​ล่ม​เป็น​เวลา​นาน framework มัก​มี second-level retries ที่​ให้​เรา​ตั้ง​ระยะ retry ที่​ยาว​ขึ้น​ได้ (เช่น ทุก 10 นาที เป็น​เวลา 1 ชั่วโมง) ส่วน message ที่​ไม่มี​วัน​สำเร็จ​ไม่​ว่า​จะ​ลอง​กี่​ครั้ง​ก็ตาม เรา​เรียก​ว่า poison message ซึ่ง​สุดท้าย​แล้ว​จะ​ไป​จบ​ลง​ที่ error queue เพื่อ​ให้​คน​เข้า​มา​จัดการ​ด้วย​ตนเอง


เมื่อ​แต่ละ Bounded Context มี​ฐาน​ข้อมูล​ของ​ตัวเอง​และ​สื่อสาร​กัน​ผ่าน message เรา​จะ​พบ​กับ​สถานะ​ที่​ไม่มี​ทาง​เกิด​ขึ้น​ใน​ระบบ transaction ใหญ่ เช่น “order ที่​ยัง​ไม่​ได้​ยืนยัน​การ​ชำระ​เงิน” ใน​ระบบ transaction นั้น การ​สร้าง order กับ​การ​ตัด​เงิน​เป็น atomic operation เดียวกัน หาก​จ่าย​ไม่​ผ่าน​ก็​จะ​ไม่มี order เกิด​ขึ้น​เลย แต่​ระบบ messaging ตัด transaction ใหญ่​ทิ้ง​ไป จึง​กลาย​เป็น Eventual ConsistencyEventual Consistencyความ​ถูกต้อง​ที่ “ตาม​มา​ทีหลัง” ไม่ใช่​ทันที ใช้​กับ​การ​อัปเดต​ข้าม Aggregate ผ่าน Domain Event แทน​การ​บังคับ​ให้​ตรง​กัน​ใน​ทันที​ภายใน transaction เดียวTactical Design — สถานะ​จะ “ตรง​กัน​ใน​ที่สุด” ไม่ใช่​ตรง​กัน​ใน​ทันที

หัวใจ​ของ​การ​อยู่​ร่วม​กับ​มัน​คือ การ​จัดการ​ประสบการณ์​ของ​ผู้​ใช้ ใน​ระบบ consistent ผู้​ใช้​ต้อง​รอ​จน​ทั้ง transaction เสร็จสิ้น​แล้ว​จึง​จะ​รู้​ว่า​สำเร็จ แต่​ใน​ระบบ eventually consistent ผู้​ใช้​มักได้​รับคำ​ยืนยัน ใน​ทันที ว่า “รับคำ​สั่ง​ซื้อ​แล้ว” จาก​นั้น​จึง​ค่อย​ส่ง​อีเมล​ยืนยัน​การ​ชำระ​เงิน​และ​การ​จัด​ส่ง​ตาม​มา​ใน​ภายหลัง

พลิก Eventual Consistency ให้​เป็น​ข้อดี

แทนที่​จะ​มอง eventual consistency เป็น​ปัญหา ให้ หัน​กลับ​ไป​ถาม​ฝ่าย​ธุรกิจ​ว่า “ใน​สถานะ​ที่​ยัง​ไม่​ตรง​กัน​นี้ ควร​เกิด​อะไร​ขึ้น?” บ่อย​ครั้ง​คำถาม​นี้​จะ​ปลด​ล็อก business policy หรือ​โอกาส​ทาง​ธุรกิจ​ที่​ซ่อน​อยู่​โดยที่​เรา​คาด​ไม่​ถึง (Udi Dahan เขียน​บทความ “Race Conditions Don’t Exist” ที่​อธิบาย​มุมมอง​นี้​ไว้​ได้​ดี​มาก)

หาก​การ​ชำระ​เงิน​ถูก​ปฏิเสธ เรา​จะ rollback ไม่​ได้ เพราะ order ถูก​สร้าง​ไป​แล้ว​ใน transaction ก่อนหน้า และ​อยู่​ใน​ฐาน​ข้อมูล​ของ​อีก context หนึ่ง สิ่ง​ที่​ทำได้​คือ roll forward ไป​สู่​สถานะ​ใหม่ เช่น publish PaymentRejected ออก​ไป แล้ว​ให้ Sales จัดการ​ด้วย​การ​ส่ง​อีเมล​แจ้ง​ลูกค้า​ว่า​คำ​สั่ง​ซื้อ​ถูก​ยกเลิก การ​ยอม​ให้​ลูกค้า​ส่วน​น้อย​ที่​จ่าย​ไม่​ผ่าน​ได้​รับ​ประสบการณ์​ที่​ด้อย​ลง​เล็กน้อย เพื่อ​แลก​กับ​การ​ให้​ลูกค้า​ที่​เหลือ​ทั้งหมด​ได้​ใช้​ระบบ​ที่​เร็ว​และ​ทนทาน มัก​เป็น trade-off ที่​ยอมรับ​ได้

🛒 e-commerce: โฟลว์เมื่อ​จ่าย​ไม่​ผ่าน

ลูกค้า​กด​สั่ง​ซื้อ → Sales สร้าง order และ​ตอบ “รับคำ​สั่ง​ซื้อ​แล้ว” ใน​ทันที → Sales publish OrderCreated → Billing พยายาม​ตัด​บัตร​แต่ ไม่​ผ่าน → Billing publish PaymentRejected → Sales ซึ่ง subscribe event นี้​อยู่​ก็​ส่ง​อีเมล “การ​ชำระ​เงิน​ไม่​สำเร็จ คำ​สั่ง​ซื้อ​ถูก​ยกเลิก” นี่​คือ​การ roll forward — เรา​ไม่​ได้​ลบ order ทิ้ง แต่​พา​ระบบ​เดิน​หน้า​ไป​สู่​สถานะ​ใหม่​ที่​สะท้อน​กระบวนการ​จริง​ของ​ธุรกิจ


มา​ถึง​ขั้น​ที่ Shipping ต้อง​จัด​ส่ง แต่​กลับ​เจอ​ปัญหา: PaymentAccepted มี​แค่ orderId — แล้ว​จะ​รู้​ที่​อยู่​ของ​ลูกค้า​ได้​อย่างไร? ทาง​เลือก​มี​อยู่​หลาย​แบบ แต่ละ​แบบ​ก็​มี​กับดัก​ของ​มัน:

  • RPC ข้าม​ไป​ถาม Sales.Customers → สร้าง temporal coupling: ถ้า DB หรือ endpoint ของ Sales ล่ม Shipping ก็​ทำงาน​ไม่​ได้ ทั้ง​ทีม Sales เอง​ก็​จะ refactor DB ของ​ตัวเอง​ไม่​ได้​เพราะ​กลัว​จะ​ทำให้ context อื่น​พัง
  • ยัด​ที่​อยู่​ลง​ไป​ใน PaymentAccepted → อันตราย เพราะ​กลาย​เป็นการ “พอก” property ของ message ก่อนหน้า​เข้า​ไป​เรื่อยๆ จน​กระทั่ง message ตัว​ที่​สี่​ต้อง​แบก​ข้อมูล​ของ​สาม​ตัว​แรก​ไป​ด้วย ทำให้ coupling สูง​และ debug ยาก
  • เก็บ​ข้อมูล​ที่​ต้อง​ใช้​ไว้ local → ทาง​เลือก​ที่​หลาย​ทีม​เลือก​ใช้ เพราะ​ใน​ยุค​นี้ storage มี​ราคา​ถูก​มาก

รูปที่ 12-22: หลาย Bounded Context เก็บข้อมูลเดียวกันไว้ local — เมื่อ Sales publish NewBusinessUserRegistered ทั้ง Shipping (เก็บ Userid+Address ลง MS SQL) และ Marketing (เก็บ Userid+Address+Market ลง RavenDB) ต่างก็ subscribe และเก็บสำเนาของตัวเอง

“Duplicate” แต่​ที่​จริง​ข้อมูล​คนละ​ความหมาย

หลาย​คน​กังวล​ว่าการ​เก็บ​ข้อมูล​ซ้ำ​คือ​ความ​สิ้น​เปลือง แต่​ความ​จริง​แล้ว ข้อมูล​เหล่า​นั้น​มัก​ต่าง​กัน​ใน​เชิง​แนวคิด เช่น Billing เก็บ “ราคา​ปัจจุบัน” (ไว้​คิด​เงิน) ส่วน Sales เก็บ “ราคา ณ ตอน​สั่ง​ซื้อ” (ไว้​คืน​เงิน​ตาม​ที่​ลูกค้า​จ่าย​จริง) คำ​ว่า duplicate จึง​ไม่​ถูกต้อง​เสมอ​ไป — เพราะ​ข้อมูล​ถูก​ใช้​คนละ​วัตถุประสงค์​และ​เปลี่ยนแปลง​ด้วย​เหตุผล​ที่​ต่าง​กัน​โดย​สิ้นเชิง

ปัญหา​ที่​ตาม​มา​คือ ข้อมูล​อาจ​ไม่​ตรง​กัน​ชั่วคราว (เช่น context หนึ่ง​อัปเดต​ราคา​ก่อน​อีก context หนึ่ง) ซึ่ง​แก้​ได้​ด้วย​การ​แนบ validity period (AvailableFrom / AvailableTo) ไป​กับ message ราคา และ​ส่ง​ล่วงหน้า​ให้​เร็ว​ที่สุด ส่วน​ปัญหา message มา​ถึง​ผิด​ลำดับ (เช่น PaymentAccepted มา​ถึง​ก่อน message ที่​อยู่​ของ​ลูกค้า) ก็​แก้​ได้​ง่ายๆ ด้วย​การ​ใส่ message กลับ​เข้า queue แล้ว retry ใน​ภายหลัง ซึ่ง​กว่า​จะ​ถึง​ตอน​นั้น message ที่​อยู่​ก็​มัก​ถูก​ประมวล​ผล​ไป​เรียบร้อย​แล้ว

🚢 Cargo: ทำไม Handling Event ถึง​ต้อง​เก็บ​แยก

ใน​ระบบ​ขนส่ง Handling EventHandling Eventการ​บันทึก​การ​จัดการ​สินค้า​จริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยก​ต่างหาก” จาก Cargo เพราะ​มี​ปริมาณ​มาก​และ​ต้อง​ประมวล​ผล​แบบ asyncTactical Design (LOAD/UNLOAD/RECEIVE/CLAIM) เกิด​ขึ้น​เป็น​จำนวน​มหาศาล​จาก​ท่าเรือ​ทั่ว​โลก​แบบ asynchronous การ​ให้ Tracking context เก็บ​สำเนา​ข้อมูล Cargo ที่​มัน​จำเป็น​ต้อง​ใช้​ไว้ local — แทนที่​จะ RPC ไป​ถาม Booking context ทุก​ครั้ง — ทำให้​มัน​สามารถ​รายงาน​สถานะ​ได้​แม้ Booking จะ​ออฟไลน์​อยู่​ก็ตาม นี่​คือ​หลักการ local storage เดียวกัน​กับ​ใน​ระบบ e-commerce


การ “ดัน” ข้อมูล​เข้า​สู่​ระบบ messaging เป็น​เพียง​ครึ่ง​หนึ่ง​ของ​เรื่องราว อีก​ครึ่ง​คือ​การ “ดึง” ข้อมูล​ออก​มา​แสดง​ผล เมื่อ​แต่ละ business component มี DB เป็น​ของ​ตัวเอง วิธี​ที่​สม​เหตุ​สม​ผล​คือ ให้​แต่ละ component เปิด HTTP API ของ​ตัวเอง​ออก​มา แล้ว​ให้​เว็บ​ไป​ดึง​ข้อมูล​เอง

ระวัง Server-Side Orchestration

การ query ทุก API ที่​ฝั่ง server (ใน controller) แล้ว​รวม​เป็น model เดียว​ส่ง​ไป​ยัง​หน้า​เว็บ​นั้น​ฟัง​ดู​สะดวก แต่​กลับ ทำลาย​ความ​พยายาม​ทั้งหมด​ที่​เรา​ลงแรง​สร้าง loose coupling มา — เพราะ​หาก API ตัว​ใด​ตัว​หนึ่ง​ล่ม ทั้ง​หน้า​ก็​อาจ render ไม่​ได้​เลย ลอง​นึกถึง​หน้า​รายการ​สินค้า​ที่​มี “ข้อ​เสนอ​พิเศษ” อยู่​ด้าน​ล่าง: หาก Marketing context ล่ม ธุรกิจ​คง​ไม่​อยาก​ให้​ลูกค้า​เห็น error จน​ซื้อ​สินค้า​ไม่​ได้​เป็น​แน่ ทางออก​ที่​นิยม​คือ​ให้​แต่ละ​ส่วน​ของ​หน้า ดึง​ข้อมูล​ของ​ตัวเอง​ด้วย AJAX (UI composition) — เมื่อ Marketing ล่ม ก็​เพียง​แค่​ส่วน​ข้อ​เสนอ​พิเศษ​หาย​ไป ส่วน​รายการ​สินค้า​ยัง​ขาย​ได้​ตาม​ปกติ


สัก​วัน​หนึ่ง​คุณ​จะ​ต้อง​เปลี่ยน​รูปแบบ​ของ message อย่าง​แน่นอน แต่ message format นั้น​คือ สัญญา (contract) ระหว่าง​ทีม หาก​เปลี่ยน​แล้ว deploy ทันที ระบบ​ที่​ยัง​ไม่​อัปเดต​ก็​จะ​พัง เป้าหมาย​ของ SOA แบบ loose coupling คือ ทีม​ต้อง​ไม่​ถ่วง​กัน​และ​กัน เรา​จึง​ต้อง​มุ่ง​สู่ backward compatibility

วิธี​คลาสสิก (เช่น​ใน NServiceBus ผ่าน polymorphic handler) คือ สร้าง version ใหม่​ที่ inherit ต่อ​จาก version เดิม:

OrderCreated_V2.ts
class OrderCreatedV2 extends OrderCreated {
addressId!: string; // เพิ่ม field ใหม่
}

ขั้นตอน​มี​ดังนี้: สร้าง OrderCreated_V2 ที่​สืบทอด​จาก version เดิม → อัปเดต​เฉพาะ handler ที่ “สนใจ” field ใหม่​ให้​รับ version ใหม่ → จาก​นั้น​ให้​ผู้​ส่ง publish version ใหม่​ออก​ไป ผลลัพธ์​คือ subscriber ที่​ไม่​สนใจ field ใหม่​ไม่​ต้อง​แก้ไข​อะไร​เลย (เพราะ OrderCreatedV2 ก็​ยัง​คง​เป็น OrderCreated อยู่​นั่นเอง)

ไม่ใช่​ทุก​ทีม​ต้อง​สนใจ​การ​เปลี่ยนแปลง

สมมุติ​ว่า​ธุรกิจ​อนุญาต​ให้​ลูกค้า​มี​ได้​หลาย​ที่​อยู่ OrderCreated จึง​ต้อง​เพิ่ม addressId เข้า​มา ฝั่ง Shipping จำเป็น​ต้อง​รู้ (จะ​ส่ง​ของ​ไป​ที่ไหน) แต่ Billing ไม่​จำเป็น​ต้อง​สนใจ (เพราะ​ออกบิล​ทาง​อีเมล) — backward-compatible versioning ทำให้ Shipping อัปเกรด​ไป​ใช้ V2 ได้ ใน​ขณะ​ที่ Billing ยัง​ใช้ version เดิม​ต่อ​ไป​ได้ โดย​ไม่​ต้อง​นัด​ประชุม​เพื่อ deploy พร้อม​กัน นี่​คือ​หัวใจ​ของ​การ​ที่​ทีม​แต่ละ​ทีม​ไม่​ถ่วง​กัน​และ​กัน


คุณ​เลือก​ใช้ messaging เพราะ​มัน​ให้​อิสระ​และ loose coupling แต่​กลับ​พบ​ว่า​ตัวเอง​ผูก​ติด​อยู่​กับ framework เพียง​ตัว​เดียว (เช่น NServiceBus) ซึ่ง​นี่​ก็​คือ coupling อีก​รูปแบบ​หนึ่ง — platform coupling ปัญหา​นี้​มี​ทาง​แก้​ที่​รู้จัก​กัน​ดี นั่น​คือ pattern ที่​ชื่อ messaging bridge

สมมุติ​ว่า​บริษัท​คุณ​ซื้อ​สตาร์ท​อัพ​ที่​ทำ​ระบบ​โปรโมชัน​ด้วย framework คนละ​ตัว (Mass Transit) และ​คุณ​ต้องการ​ให้​มัน subscribe OrderCreated event ของ Sales แต่มันทำตรงๆ ไม่​ได้​เพราะ​เป็น​คนละ framework กัน

รูปที่ 12-28: Promotions Bounded Context (Mass Transit) ต้องการ subscribe Order Created event จาก Sales Bounded Context (NServiceBus) — แต่เป็นคนละ framework กัน จึงเชื่อมโดยตรงไม่ได้

messaging bridge คือ app ตัวกลาง​ที่​ทำ​หน้าที่​รับ message จาก​ระบบ​หนึ่ง แล้ว​ส่ง​ต่อ​เข้าไป​ยัง​อีก​ระบบ​หนึ่ง ใน​ตัวอย่าง​นี้​คือ​การ​สร้าง subscriber ฝั่ง NServiceBus ที่​รับ OrderCreated แล้ว​แปลง​รูปแบบ (เช่น แปลง​เป็น XML ที่ Mass Transit เข้าใจ) จาก​นั้น​จึง​หย่อน​ลง queue ที่​อีก​ฝั่ง​คอย​อ่าน​อยู่

flowchart LR
  subgraph A["ระบบ A — framework A"]
    SA[Sales] --> EP1[Endpoint]
  end
  Bridge["Messaging Bridge<br/>(รับจาก A → แปลง → ส่งเข้า B)"]
  subgraph B["ระบบ B — framework B"]
    EP2[Endpoint] --> PR[Promotions]
  end
  EP1 --> Bridge --> EP2
Bridge ไม่ใช่​ทางออก​ที่​ง่าย​เสมอ

การ​สร้าง bridge นั้น​ต้อง​เข้าใจ format ที่​แต่ละ framework คาด​หวัง และ​ต้อง​ลง​ไป​จัดการ queue ด้วย​ตัวเอง​ใน​ระดับ abstraction ที่​ต่ำ​ลง หาก​พบ​ว่าการ​สร้าง bridge เริ่ม​ไม่​คุ้ม​ค่า​สำหรับ project แต่​ยัง​ต้องการ scalability และ fault tolerance แบบ reactive — REST และ​โซลูชัน​ที่​อยู่​บน HTTP อาจ​เป็น​ทาง​เลือก​ที่​ดี​กว่า (ซึ่ง​จะ​เป็น​เนื้อหา​ใน​บท​ถัด​ไป)


เมื่อ​ระบบ​รัน​จริง เรา​ต้อง เฝ้า​ระวัง error เพื่อ​จับ bug ใน production ให้​ได้​เร็ว​ที่สุด poison message จะ​ไป​กอง​อยู่​ที่ error queue ซึ่ง​ต้อง​มี​คน​คอย​เข้า​มา​ตรวจสอบ และ​เมื่อ​แก้​ปัญหา​เสร็จ​แล้ว framework มัก​มี​เครื่องมือ​สำหรับ​ส่ง message กลับ​เข้า queue เดิม​เพื่อ​ลอง​ใหม่​อีก​ครั้ง

นอกจาก​นี้​เรา​ยัง​ต้อง วัดผล SLA และ business metrics ด้วย: สอง​ชั่วโมง​ที่​ผ่าน​มา​มี​ออเดอร์​เข้า​มา​กี่​รายการ? แต่ละ​ออเดอร์​ใช้​เวลา​ประมวล​ผล​นาน​เท่าไร? payment provider ตอบ​สนอง​ช้า​หรือ​ไม่? metric เหล่า​นี้​ช่วย​ทั้ง​ให้​ธุรกิจ​ตัดสิน​ใจ และ​บอก​ทีม​เทค​ว่า​ถึง​เวลา​ที่​ต้อง scale out แล้ว​หรือ​ยัง

การ scale out ใน​ระบบ messaging คือ การ​รัน​หลาย instance ของ​คอมโพเนนต์เดียวกัน​ให้​เข้า​มา​ช่วย​กัน​กิน message จาก queue เดียวกัน (NServiceBus ใช้​ตัว​ช่วย​ที่​ชื่อ distributor) แต่​ก่อน​จะ​ลงทุน​เพิ่ม​ฮาร์ดแวร์ ลอง​ปรับ concurrency บน​เครื่อง​เดียว​ดู​ก่อน (เช่น เพิ่ม​จำนวน thread) ซึ่ง​มัก​ช่วย​ได้​โดย​ไม่​ต้อง​เพิ่ม server เลย

สรุป​แก่น​ของ​บท​นี้

เชื่อม Bounded Context เข้า​ด้วย​กัน​ด้วย asynchronous messaging เพื่อ​แลก​มา​ซึ่ง scalability และ fault tolerance โดย ใช้ domain event เป็น message เพื่อ​ให้ code พูด​ภาษา​เดียว​กับ​ธุรกิจ แยก command (สั่ง​ให้​เกิด, จัดการ​ที่​เดียว) ออก​จาก event (เกิด​ขึ้น​แล้ว, มี​ได้​หลาย subscriber) ให้​ชัดเจน, ห่อ​การ​เรียก​บริการ​ภายนอก​ด้วย messaging gateway, อยู่​ร่วม​กับ eventual consistency ด้วย​แนวคิด roll forward แทน​การ rollback, เก็บ​ข้อมูล​ที่​จำเป็น​ไว้ local เพื่อ​ตัด coupling, รักษา backward compatibility ของ message ไว้​เสมอ​เพื่อ​ไม่​ให้​ทีม​ถ่วง​กัน​และ​กัน และ​ใช้ messaging bridge เชื่อม​ระบบ​ต่าง platform (แต่​บาง​ครั้ง REST ก็​อาจ​เป็น​ทาง​ที่​ง่าย​กว่า)


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

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

  • Domain Events — แก่น​ของ​บท​นี้​คือ​การ​ใช้ event ที่​เกิด​จริง​ใน​ธุรกิจ​เป็น message; หน้า​นี้​ลง​รายละเอียด pattern การ​ใช้ event ใน​สถาปัตยกรรม​เชิง​ลึก​กว่า​นี้
  • Event Storming — เทคนิค​ที่​ใช้​ค้นหา domain event ร่วม​กับ domain expert ก่อน​ออกแบบ component diagram ตาม​ที่​บท​นี้​แนะนำ
  • Temporal Coupling — กลิ่น code ที่​บท​นี้​อ้างถึงตรงๆ เมื่อ​อธิบาย​ว่า​ทำไม RPC ข้าม Bounded Context ไป​ถาม​ข้อมูล​จึง​อันตราย​กว่า​การ​เก็บ​ข้อมูล​ไว้ local
  • Postel’s Law — หลักการ “ยอมรับ​แบบ​ผ่อนปรน ส่ง​ออกแบบ​เข้มงวด” ที่​อยู่​เบื้องหลัง backward-compatible message versioning ใน​บท​นี้​โดยตรง

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

ข้อ 1 / 4

command กับ event ต่างกันอย่างไรตามหนังสือ?