An Insurance Policy for Free
คำตอบและหมุดเชื่อมโยง: คำว่า "An insurance policy for free" (กรมธรรม์ประกันภัยฟรีไม่มีค่าใช้จ่าย) มาจากคดีความจริงในชั้นศาล เมื่อบริษัทประกันภัยแห่งหนึ่งถูกลูกค้าฟ้องร้องให้ชดใช้ค่าเสียหายจากอุบัติเหตุรถชน ทั้งๆ ที่ลูกค้า "ยังไม่ได้ชำระเงินค่าเบี้ยประกันแม้แต่บาทเดียว" แต่กลับมีหนังสือรับรองกรมธรรม์ฉบับจริงส่งไปถึงบ้านของลูกค้าแล้ว!
ผู้เขียนตั้งชื่อบทนี้เพื่อชี้ให้เห็นสภาวะอันตรายที่สุดของการสถาปัตยกรรม Microservices คือ "ระบบย่อยทุกระบบทำงานถูกต้อง 100% ตาม Specification ของตัวเอง แต่เมื่อนำมารวมกันเป็น System of Systems ระบบรวมกลับพังทลายลงสิ้นเชิง" สาเหตุเกิดจาก Microservices Myopia (การมองไมโครเซอร์วิสแบบคับแคบ) เมื่อแต่ละทีมตีความคำว่า Payment (การชำระเงิน) ข้าม Bounded Context แตกต่างกัน จนนำไปสู่ช่องโหว่ทางตรรกะที่ออกสัญญาคุ้มครองให้ลูกค้าไปฟรีๆ ครับ!
Days 1-7: รากฐาน DDD, Immutability, Domain Primitives, State Integrity และ Entity Relay
Day 11 จาก 14 วัน — บทพักครึ่งวิเคราะห์กรณีศึกษาระบบประกันภัย ก่อนเข้าสู่ Legacy Code และ Microservices
เข้าใจภัยเงียบของ Microservices Myopia, การตีความความหมายข้าม Bounded Context และใช้ Context Mapping กอบกู้ระบบ
เรื่องราวและมโนทัศน์เชิงลึก (The Insurance Case & Semantic Architecture)
🏛️ ยุค Monolith ดั้งเดิม (เงินสดหน้าร้าน)
ในอดีต บริษัทประกันภัยดำเนินธุรกิจแบบ Over-the-Counter ลูกค้าเดินเข้ามาที่สาขา ยื่นเอกสาร และ "จ่ายเงินสดชำระเบี้ยประกันที่เคาน์เตอร์" พนักงานบันทึกข้อมูลเข้าระบบ Monolith เมื่อระบบได้รับ Event Payment มันจะสร้าง PolicyPeriod (ขยายเวลาคุ้มครอง) และพิมพ์หนังสือรับรองส่งทางไปรษณีย์ทันที
Payment มีความหมายเดียวอย่างชัดเจน คือ "ได้รับเงินสดครบถ้วน 100% แล้ว"
✂️ การแยกออกเป็น 2 Microservices
เมื่อองค์กรเติบโต ระบบเริ่มใหญ่เกินไป ทีมงานจึงแยก Monolith ออกเป็น 2 บริการตาม Bounded Context:
Payment ข้าม Message Queue ไปหา Policy เพื่อสั่งพิมพ์จดหมาย ซึ่งในระยะแรก ทุกอย่างยังทำงานถูกต้องดี
🏦 ช่องทางใหม่: หักบัญชีอัตโนมัติ (Bank Giro)
ฝ่ายการตลาดเพิ่มช่องทางชำระเงินผ่าน Bank Giro (Direct Debit) โดยระบบธนาคารจะส่ง Message 3 ชนิดมาให้ Finance Service:
Payment จากธนาคาร จึงนำไป Map เข้ากับ Event Payment เดิมของตนเองแล้วยิงต่อให้ Policy ทันที! ฝั่ง Policy คิดว่าลูกค้าจ่ายเงินสดแล้ว จึงพิมพ์หนังสือคุ้มครองส่งไปบ้านลูกค้าทันที!
🚗💥 อุบัติเหตุรถชน และคำพิพากษาศาล
นาย B สมัครหักเงินผ่าน Bank Giro ถึงวันต่ออายุ ธนาคารส่ง Payment (แจ้งรับเรื่อง) ให้ Finance -> Finance ยิงต่อให้ Policy -> Policy พิมพ์หนังสือคุ้มครองส่งไปบ้านนาย B
Bounce มาบอกว่าหักเงินล้มเหลว แต่ Finance เมินเฉยเพราะรอฟังแค่ Payment!
ต่อมานาย B ขับรถชนต้นไม้พังยับเยิน ถือหนังสือคุ้มครองมาเคลมประกัน บริษัทปฏิเสธว่า "คุณยังไม่ได้จ่ายเงิน!" นาย B ฟ้องศาลทันที
🔍 ใครเป็นคนสร้าง Bug นี้ขึ้นมา?
ความจริงที่น่าตกใจคือ "ไม่มีโค้ดของบริการไหนพังเลยสักบริการเดียว!"
🛡️ การแก้ไขด้วย Context Mapping
จัด Workshop นำ Domain Experts ของทุกฝ่ายมาคุยกัน (Deliberate Discovery) แล้วสร้าง Context Map ประสานความหมายใหม่:
Payment ลอยๆ แต่แยกเป็น CashPayment (ได้เงินสดแล้ว) และ GiroPayment (อยู่ระหว่างขอหักเงิน)
PolicyPaymentReceived ไปหา Policy Service ก็ต่อเมื่อได้รับ Message Confirm จากธนาคารเท่านั้น!
ส่ง Message: Payment (ลงทะเบียน), Confirm (หักเงินสำเร็จ), หรือ Bounce (เงินไม่พอ)
ถ้าเจอ Payment ➔ บันทึกรอ / ถ้าเจอ Bounce ➔ ยกเลิก / ถ้าเจอ Confirm ➔ ยิง Event ยืนยันเงินเข้า
รอรับเฉพาะ Event PolicyPaymentReceived เท่านั้น จึงจะออกใบรับรองกรมธรรม์ คุ้มครองอย่างปลอดภัย 100%!
แบบฝึกหัดปฏิบัติตามสถานการณ์จริง (Hands-on Refactoring)
// โค้ดเดิมฝั่ง Finance: ตีความ Message "Payment" จากธนาคารผิดอย่างมหันต์ public class BankGiroIntegrationListener { private EventPublisher eventPublisher; public void onBankGiroMessage(BankGiroMessage message) { // 💥 ช่องโหว่ร้ายแรง: Message "Payment" ของธนาคารเป็นแค่การ "ลงทะเบียนคำขอ" // แต่ดันยิง Event "PaymentCompleted" ไปหลอก Policy Service ว่าได้รับเงินแล้ว! if ("Payment".equals(message.getType())) { eventPublisher.publish(new PaymentCompletedEvent( message.getPolicyId(), message.getAmount() )); } // 💥 Message "Confirm" และ "Bounce" ถูกเพิกเฉยทิ้งไปเลย! } }
Bounce มา แต่ระบบไม่สนใจ Policy Service ส่งหนังสือคุ้มครองไปถึงบ้านลูกค้าแล้ว ศาลจึงสั่งให้บริษัทต้องจ่ายค่าสินไหมรถชนฟรี!
// โค้ดใหม่ฝั่ง Finance: แยกแยะสถานะ Bank Giro อย่างชัดเจนตาม Bounded Context public class BankGiroIntegrationListener { private final EventPublisher eventPublisher; private final GiroTransactionRepository giroRepository; public BankGiroIntegrationListener(EventPublisher ep, GiroTransactionRepository repo) { this.eventPublisher = notNull(ep); this.giroRepository = notNull(repo); } public void onBankGiroMessage(BankGiroMessage message) { notNull(message, "BankGiroMessage required"); switch (message.getType()) { case "Payment": // 1. แค่บันทึกคำขอรอดำเนินการ ห้ามยิง Event แจ้งว่าได้เงินแล้วเด็ดขาด! giroRepository.recordPendingGiro(message.getTransactionId(), message.getPolicyId()); break; case "Confirm": // 2. หักเงินสำเร็จจริงแล้ว จึงยิง Event ข้าม Bounded Context! GiroTransaction tx = giroRepository.markAsConfirmed(message.getTransactionId()); eventPublisher.publish(new PolicyPaymentReceivedEvent(tx.policyId(), tx.amount())); break; case "Bounce": // 3. เงินในบัญชีไม่พอ บันทึกล้มเหลว ไม่มีการออกกรมธรรม์ giroRepository.markAsFailed(message.getTransactionId()); break; } } }
Confirm จากธนาคารจริงๆ ปิดช่องโหว่การแจกกรมธรรม์ประกันภัยฟรี 100%!
ชุดควิซประเมินความรู้ประจำวัน
ตอบปุ๊บ รู้ผลเฉลยทันที พร้อมคำอธิบายและหลอดสะสมพลังงาน Energy Charge
ทดสอบความรู้ Day 11 สำเร็จแล้ว!
สรุปการเชื่อมโยงกลับสู่ Chapter 11 — Intermission: An insurance policy for free
คดีความกรมธรรม์ประกันภัยรถชนฟรีในวันนี้ เป็นอุทาหรณ์ที่เด่นชัดของหัวข้อบท "Intermission: An insurance policy for free" อย่างสมบูรณ์แบบว่า เมื่อเราแยกระบบออกเป็น Microservices โดยขาดการทำ Context Mapping ที่ดี ความหมายของข้อมูลที่สะดุดข้าม Bounded Context จะกลายเป็นช่องโหว่ที่แจกสินค้าหรือบริการให้ผู้ใช้ไปฟรีๆ โดยที่โค้ดของแต่ละบริการไม่ได้มีบั๊กทางเทคนิคเลยแม้แต่บรรทัดเดียว! การดึง Domain Experts ทุกฝั่งมาร่วมออกแบบความหมายข้ามระบบและใช้ Explicit Domain Events จึงเป็นเกราะคุ้มกันเดียวที่จะป้องกันโศกนาฏกรรมเช่นนี้ได้ครับ!