เชื่อม 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 ที่เราต้องเรียนรู้ที่จะอยู่ร่วมกับมันให้ได้
พื้นฐานของระบบ Messaging
หัวข้อที่มีชื่อว่า “พื้นฐานของระบบ Messaging”ระบบที่สื่อสารผ่าน message นั้นต่างจากระบบ OO แบบดั้งเดิมที่ทำงานอยู่ใน process เดียวกันอย่างสิ้นเชิง มันเปิดประโยชน์ใหม่ๆ ให้เรา แต่ก็เรียกร้องวิธีคิดแบบ asynchronous ที่ต้องปรับ mindset พอสมควร เราจะเริ่มจากชิ้นส่วนที่เป็นกาวยึดทุกอย่างเข้าด้วยกัน — message bus
Message Bus
หัวข้อที่มีชื่อว่า “Message Bus”ถ้ามีคอมโพเนนต์ส่วนกลางเพียงตัวเดียวรับผิดชอบส่งต่อ message ทั้งหมด ทั้งระบบจะล่มทันทีเมื่อมันพัง และมันยังกลายเป็นคอขวดที่ทำให้ขยายแต่ละส่วนได้ยากอีกด้วย message bus แก้ปัญหานี้ด้วยการเป็น ระบบกระจาย (distributed) ที่มี “agent” เล็กๆ รันอยู่ในทุกคอมโพเนนต์ที่ต้องส่งหรือรับ message จึงไม่มีจุดล้มเหลวจุดเดียว (single point of failure)
ถ้าให้คอมโพเนนต์ส่วนกลางตัวเดียวรับและส่ง message ทั้งหมด เราจะเรียกมันว่า broker ในอดีต broker มักถูกมองข้ามเพราะกังวลเรื่องการล่มและการขยายตัว แต่ broker สมัยใหม่อย่าง Apache Kafka สามารถขยายแบบ horizontal ได้และรองรับ feature ของ bus ได้เกือบครบ แนวคิดในบทนี้จึงนำไปใช้กับระบบที่ใช้ broker สมัยใหม่ได้เช่นกัน
Reliable Messaging — ส่งให้ถึงแน่ๆ
หัวข้อที่มีชื่อว่า “Reliable Messaging — ส่งให้ถึงแน่ๆ”การที่ message “หายไป” กลางทางอาจมีต้นทุนสูงมาก — ลูกค้าย่อมไม่พอใจแน่หากถูกตัดบัตรเครดิตแล้วไม่ได้รับสินค้า เพียงเพราะ message สั่งจัดส่งหายไประหว่างทาง น่าเสียดายที่แทบเป็นไปไม่ได้ที่จะรับประกันการ “ส่งถึงเพียงครั้งเดียวเป๊ะๆ” (exactly-once) เพราะหากส่งไปแล้วไม่ได้รับ acknowledgement กลับมา เราก็ต้องส่งซ้ำ — แต่จะรู้ได้อย่างไรว่าฝั่งปลายทางรับไปแล้ว เพียงแต่ ack หายระหว่างทางเท่านั้น?
ในทางปฏิบัติ แนวทางที่นิยมที่สุดคือ at-least-once delivery (ส่งจนกว่าจะถึงปลายทางอย่างน้อยหนึ่งครั้ง) ซึ่งมีความเสี่ยงว่า message อาจถูกประมวลผลซ้ำ เราจึงต้องจับคู่มันเข้ากับ idempotent message
Idempotent message คือ message ที่ส่งกี่ครั้งก็ให้ผลลัพธ์เหมือนส่งเพียงครั้งเดียว เช่น message สั่งตัดเงิน ถ้ามาถึงสองครั้ง ฝั่งรับจะประมวลผลแค่ครั้งแรกเท่านั้น มักทำได้ด้วยตัวระบุที่ไม่ซ้ำกัน (เช่น payment reference) — การจับคู่ at-least-once เข้ากับ idempotency คือสูตรสำเร็จที่ทำให้ระบบทั้งเชื่อถือได้และไม่ตัดเงินลูกค้าซ้ำ
Store-and-Forward
หัวข้อที่มีชื่อว่า “Store-and-Forward”กลไกเบื้องหลังการส่งซ้ำคือ pattern ที่ชื่อ store-and-forward: ก่อนส่ง message เราจะ เก็บมันลง queue ไว้ก่อน ถ้าส่งถึงและได้รับ ack สำเร็จ ก็ลบสำเนาทิ้ง แต่ถ้าส่งไม่ถึง ก็ลองใหม่ — messaging framework ส่วนใหญ่จัดการความซับซ้อนนี้ให้เรา โดยเปิดให้เราตั้งกฎได้ว่าจะ retry กี่ครั้งและเว้นระยะห่างเท่าไร เมื่อ Service A ส่งให้ Service B, message จะไปรออยู่บน queue ของ B; หาก B ประมวลผลไม่สำเร็จ มันก็จะถูกใส่กลับเข้า queue เพื่อลองใหม่
Command กับ Event ต่างกันอย่างไร
หัวข้อที่มีชื่อว่า “Command กับ Event ต่างกันอย่างไร”นี่คือความแตกต่างเชิงแนวคิดที่สำคัญที่สุดของบทนี้ 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 ที่มีอยู่แล้ว

ธรรมเนียมที่นิยมที่สุดคือ ตั้งชื่อ command เป็นประโยคคำสั่ง (PlaceOrder, ChangeAddress, RefundAccount) และตั้งชื่อ event เป็นอดีตกาล เพื่อบอกสิ่งที่เกิดขึ้นไปแล้ว (OrderCreated, MovedAddress, AccountRefunded) — กฎง่ายๆ ข้อนี้ทำให้ทั้งทีมอ่านโฟลว์ของระบบออกได้ในทันที
ในระบบขนส่งตู้ container การที่ลูกค้ากดจองคือ BookCargo (command — ส่งไปยังที่เดียวคือ Booking context) เมื่อจองสำเร็จ ระบบจะ publish CargoWasBooked (event) ออกมา จากนั้น context อื่นๆ ที่สนใจก็ subscribe ได้ตามต้องการ: ฝ่ายวางแผนเส้นทางรับไปจัด ItineraryItineraryValue Object ที่เป็น “แผนการเดินทางจริง” ของ Cargo ประกอบด้วยลำดับของ Leg (ช่วงการขนส่งแต่ละช่วง)Tactical Design, ฝ่ายแจ้งเตือนส่งอีเมลยืนยัน และในภายหลังหากอยากเพิ่ม feature “สะสมแต้มลูกค้า” ก็เพียงแค่เพิ่ม context ใหม่เข้ามา subscribe CargoWasBooked โดยไม่ต้องแตะต้อง Booking เลย
ออกแบบระบบก่อนเปิด IDE
หัวข้อที่มีชื่อว่า “ออกแบบระบบก่อนเปิด IDE”ก่อนลงมือเขียน code การวาดภาพสิ่งที่กำลังจะสร้างนั้นช่วยได้มาก โดยเฉพาะเมื่อแนวคิดยังใหม่ การออกแบบระบบ messaging มักเริ่มจากสามขั้นตอน แต่ขั้นที่สำคัญที่สุดคือ เริ่มจาก domain ก่อนเสมอ
เริ่มจาก Domain Event
หัวข้อที่มีชื่อว่า “เริ่มจาก Domain Event”เมื่อ 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 — โฟลว์ของตรรกะ
หัวข้อที่มีชื่อว่า “Component Diagram — โฟลว์ของตรรกะ”component diagram แสดง “การไหลของตรรกะ” ระหว่างคอมโพเนนต์ ไม่มีโครงสร้างตายตัว ใช้เพียงกล่องกับเส้น อย่าลงลึกถึงระดับชื่อ class/method และอย่าสูงถึงขั้นเลือกเทคโนโลยี — ให้คงไว้ที่ระดับของ event และกระบวนการ ไดอะแกรมชนิดนี้เหมาะกับการร่างร่วมกับ domain expert ตอนทำ knowledge-crunching แนวทางที่ดีคือ 1 diagram ต่อ1 high-level use case

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

ประเด็นสำคัญที่อ่านได้จาก diagram นี้:
- การสื่อสาร ภายใน ระหว่าง Bounded Context ใช้ messaging ทั้งหมด
- การคุยกับ payment provider ภายนอกที่ไม่น่าเชื่อถือ ถูกห่อด้วย messaging gateway เพื่อเพิ่มความเชื่อถือได้
- แต่ละ context ไม่ share ฐานข้อมูล หรือ dependency กัน — เลือกเทคโนโลยีของตัวเองได้อิสระ (C#, Ruby on Rails, MS SQL, RavenDB ฯลฯ)
รักษา diagram ให้กระชับด้วยการคงไว้ที่ ระดับ abstraction เดียวกันทั้งภาพ — แม้จะรวม containers กับ component diagram เป็นรูปเดียวได้ก็จริง แต่ข้อมูลที่มีคนละวัตถุประสงค์จะปะปนกันจนอ่านยากขึ้น Simon Brown (เจ้าของ model C4) จึงแนะนำให้แยกชั้นของ diagram ให้ชัดเจน
เมื่อเรียนรู้ domain มากขึ้นจากการทำงานร่วมกับ domain expert เราจะต้อง refactor model และปรับ UL — diagram ก็ต้องวิวัฒน์ตามไปด้วย หากมี event ใหม่เกิดขึ้น หรือขอบเขตของ context ต้องขยับ ก็ให้อัปเดต diagram ตามไปด้วย เพื่อให้มันยังคงสะท้อนความเป็นจริงและช่วยให้ผู้คนตัดสินใจเชิงธุรกิจได้ (แต่ก็ต้องจัดลำดับความสำคัญตามความต้องการของธุรกิจ — บางครั้งการส่ง feature ออกไปก่อนก็คุ้มค่ากว่าการจัดขอบเขตให้สมบูรณ์แบบ)
Messaging Gateway — ห่อการเรียกภายนอกที่ไม่น่าเชื่อถือ
หัวข้อที่มีชื่อว่า “Messaging Gateway — ห่อการเรียกภายนอกที่ไม่น่าเชื่อถือ”ลองดูตัวอย่าง code ที่ “อันตราย” กันก่อน เพื่อให้เห็นภาพว่าทำไมเราจึงต้องมี gateway สมมุติว่า handler ของ Billing เขียนไว้แบบนี้:
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 มันก็จะไม่ทำซ้ำสิ่งที่ทำไปแล้ว (การตัดบัตร)
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));}ขั้นตอน “บันทึกผลการตัดบัตร” เป็น รายละเอียดเชิงเทคนิคที่มีไว้เพื่อ 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 เพื่อให้คนเข้ามาจัดการด้วยตนเอง
อยู่กับ Eventual Consistency
หัวข้อที่มีชื่อว่า “อยู่กับ Eventual Consistency”เมื่อแต่ละ 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 เป็นปัญหา ให้ หันกลับไปถามฝ่ายธุรกิจว่า “ในสถานะที่ยังไม่ตรงกันนี้ ควรเกิดอะไรขึ้น?” บ่อยครั้งคำถามนี้จะปลดล็อก business policy หรือโอกาสทางธุรกิจที่ซ่อนอยู่โดยที่เราคาดไม่ถึง (Udi Dahan เขียนบทความ “Race Conditions Don’t Exist” ที่อธิบายมุมมองนี้ไว้ได้ดีมาก)
Roll Forward แทน Rollback
หัวข้อที่มีชื่อว่า “Roll Forward แทน Rollback”หากการชำระเงินถูกปฏิเสธ เราจะ rollback ไม่ได้ เพราะ order ถูกสร้างไปแล้วใน transaction ก่อนหน้า และอยู่ในฐานข้อมูลของอีก context หนึ่ง สิ่งที่ทำได้คือ roll forward ไปสู่สถานะใหม่ เช่น publish PaymentRejected ออกไป แล้วให้ Sales จัดการด้วยการส่งอีเมลแจ้งลูกค้าว่าคำสั่งซื้อถูกยกเลิก การยอมให้ลูกค้าส่วนน้อยที่จ่ายไม่ผ่านได้รับประสบการณ์ที่ด้อยลงเล็กน้อย เพื่อแลกกับการให้ลูกค้าที่เหลือทั้งหมดได้ใช้ระบบที่เร็วและทนทาน มักเป็น trade-off ที่ยอมรับได้
ลูกค้ากดสั่งซื้อ → Sales สร้าง order และตอบ “รับคำสั่งซื้อแล้ว” ในทันที → Sales publish OrderCreated → Billing พยายามตัดบัตรแต่ ไม่ผ่าน → Billing publish PaymentRejected → Sales ซึ่ง subscribe event นี้อยู่ก็ส่งอีเมล “การชำระเงินไม่สำเร็จ คำสั่งซื้อถูกยกเลิก” นี่คือการ roll forward — เราไม่ได้ลบ order ทิ้ง แต่พาระบบเดินหน้าไปสู่สถานะใหม่ที่สะท้อนกระบวนการจริงของธุรกิจ
เก็บข้อมูลที่ต้องใช้ไว้กับตัว (Local Storage)
หัวข้อที่มีชื่อว่า “เก็บข้อมูลที่ต้องใช้ไว้กับตัว (Local Storage)”มาถึงขั้นที่ Shipping ต้องจัดส่ง แต่กลับเจอปัญหา: PaymentAccepted มีแค่ orderId — แล้วจะรู้ที่อยู่ของลูกค้าได้อย่างไร? ทางเลือกมีอยู่หลายแบบ แต่ละแบบก็มีกับดักของมัน:
- RPC ข้ามไปถาม Sales.Customers → สร้าง temporal coupling: ถ้า DB หรือ endpoint ของ Sales ล่ม Shipping ก็ทำงานไม่ได้ ทั้งทีม Sales เองก็จะ refactor DB ของตัวเองไม่ได้เพราะกลัวจะทำให้ context อื่นพัง
- ยัดที่อยู่ลงไปใน
PaymentAccepted→ อันตราย เพราะกลายเป็นการ “พอก” property ของ message ก่อนหน้าเข้าไปเรื่อยๆ จนกระทั่ง message ตัวที่สี่ต้องแบกข้อมูลของสามตัวแรกไปด้วย ทำให้ coupling สูงและ debug ยาก - เก็บข้อมูลที่ต้องใช้ไว้ local → ทางเลือกที่หลายทีมเลือกใช้ เพราะในยุคนี้ storage มีราคาถูกมาก

หลายคนกังวลว่าการเก็บข้อมูลซ้ำคือความสิ้นเปลือง แต่ความจริงแล้ว ข้อมูลเหล่านั้นมักต่างกันในเชิงแนวคิด เช่น Billing เก็บ “ราคาปัจจุบัน” (ไว้คิดเงิน) ส่วน Sales เก็บ “ราคา ณ ตอนสั่งซื้อ” (ไว้คืนเงินตามที่ลูกค้าจ่ายจริง) คำว่า duplicate จึงไม่ถูกต้องเสมอไป — เพราะข้อมูลถูกใช้คนละวัตถุประสงค์และเปลี่ยนแปลงด้วยเหตุผลที่ต่างกันโดยสิ้นเชิง
ปัญหาที่ตามมาคือ ข้อมูลอาจไม่ตรงกันชั่วคราว (เช่น context หนึ่งอัปเดตราคาก่อนอีก context หนึ่ง) ซึ่งแก้ได้ด้วยการแนบ validity period (AvailableFrom / AvailableTo) ไปกับ message ราคา และส่งล่วงหน้าให้เร็วที่สุด ส่วนปัญหา message มาถึงผิดลำดับ (เช่น PaymentAccepted มาถึงก่อน message ที่อยู่ของลูกค้า) ก็แก้ได้ง่ายๆ ด้วยการใส่ message กลับเข้า queue แล้ว retry ในภายหลัง ซึ่งกว่าจะถึงตอนนั้น message ที่อยู่ก็มักถูกประมวลผลไปเรียบร้อยแล้ว
ในระบบขนส่ง 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
ดึงข้อมูลออกมาแสดงบน UI
หัวข้อที่มีชื่อว่า “ดึงข้อมูลออกมาแสดงบน UI”การ “ดัน” ข้อมูลเข้าสู่ระบบ messaging เป็นเพียงครึ่งหนึ่งของเรื่องราว อีกครึ่งคือการ “ดึง” ข้อมูลออกมาแสดงผล เมื่อแต่ละ business component มี DB เป็นของตัวเอง วิธีที่สมเหตุสมผลคือ ให้แต่ละ component เปิด HTTP API ของตัวเองออกมา แล้วให้เว็บไปดึงข้อมูลเอง
การ query ทุก API ที่ฝั่ง server (ใน controller) แล้วรวมเป็น model เดียวส่งไปยังหน้าเว็บนั้นฟังดูสะดวก แต่กลับ ทำลายความพยายามทั้งหมดที่เราลงแรงสร้าง loose coupling มา — เพราะหาก API ตัวใดตัวหนึ่งล่ม ทั้งหน้าก็อาจ render ไม่ได้เลย ลองนึกถึงหน้ารายการสินค้าที่มี “ข้อเสนอพิเศษ” อยู่ด้านล่าง: หาก Marketing context ล่ม ธุรกิจคงไม่อยากให้ลูกค้าเห็น error จนซื้อสินค้าไม่ได้เป็นแน่ ทางออกที่นิยมคือให้แต่ละส่วนของหน้า ดึงข้อมูลของตัวเองด้วย AJAX (UI composition) — เมื่อ Marketing ล่ม ก็เพียงแค่ส่วนข้อเสนอพิเศษหายไป ส่วนรายการสินค้ายังขายได้ตามปกติ
Message Versioning — เปลี่ยนสัญญาโดยไม่พังกัน
หัวข้อที่มีชื่อว่า “Message Versioning — เปลี่ยนสัญญาโดยไม่พังกัน”สักวันหนึ่งคุณจะต้องเปลี่ยนรูปแบบของ message อย่างแน่นอน แต่ message format นั้นคือ สัญญา (contract) ระหว่างทีม หากเปลี่ยนแล้ว deploy ทันที ระบบที่ยังไม่อัปเดตก็จะพัง เป้าหมายของ SOA แบบ loose coupling คือ ทีมต้องไม่ถ่วงกันและกัน เราจึงต้องมุ่งสู่ backward compatibility
วิธีคลาสสิก (เช่นใน NServiceBus ผ่าน polymorphic handler) คือ สร้าง version ใหม่ที่ inherit ต่อจาก version เดิม:
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 พร้อมกัน นี่คือหัวใจของการที่ทีมแต่ละทีมไม่ถ่วงกันและกัน
ผูกระบบต่าง platform ด้วย Messaging Bridge
หัวข้อที่มีชื่อว่า “ผูกระบบต่าง platform ด้วย Messaging Bridge”คุณเลือกใช้ messaging เพราะมันให้อิสระและ loose coupling แต่กลับพบว่าตัวเองผูกติดอยู่กับ framework เพียงตัวเดียว (เช่น NServiceBus) ซึ่งนี่ก็คือ coupling อีกรูปแบบหนึ่ง — platform coupling ปัญหานี้มีทางแก้ที่รู้จักกันดี นั่นคือ pattern ที่ชื่อ messaging bridge
สมมุติว่าบริษัทคุณซื้อสตาร์ทอัพที่ทำระบบโปรโมชันด้วย framework คนละตัว (Mass Transit) และคุณต้องการให้มัน subscribe OrderCreated event ของ Sales แต่มันทำตรงๆ ไม่ได้เพราะเป็นคนละ 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 นั้นต้องเข้าใจ format ที่แต่ละ framework คาดหวัง และต้องลงไปจัดการ queue ด้วยตัวเองในระดับ abstraction ที่ต่ำลง หากพบว่าการสร้าง bridge เริ่มไม่คุ้มค่าสำหรับ project แต่ยังต้องการ scalability และ fault tolerance แบบ reactive — REST และโซลูชันที่อยู่บน HTTP อาจเป็นทางเลือกที่ดีกว่า (ซึ่งจะเป็นเนื้อหาในบทถัดไป)
Monitoring และ Scaling
หัวข้อที่มีชื่อว่า “Monitoring และ Scaling”เมื่อระบบรันจริง เราต้อง เฝ้าระวัง 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:
- 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 / 4command กับ event ต่างกันอย่างไรตามหนังสือ?