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

Context Map — วาด​ความ​สัมพันธ์

บท​ที่​แล้ว​เรา​ลาก​เส้น​แบ่ง​ออเดอร์​ก้อน​เดียว​ออก​เป็น​สี่ Bounded ContextBounded Contextขอบเขต​ที่ ubiquitous language และ model หนึ่ง​ชุด​มี​ความหมาย​คง​เส้น​คง​วา นอก​ขอบเขต​นี้​คำ​เดียวกัน​อาจ​หมาย​ถึง​คนละ​อย่าง เช่น 'order' ใน Ordering กับ 'ticket' ใน Kitchen คือ​มุมมอง​ของ​สิ่ง​เดียวกัน​คนละ modelStrategic Design — Ordering (core), Kitchen (supporting), Delivery (supporting), Payment (generic) — แต่ละ​อัน​เป็น​เจ้าของ model และ​ภาษา​ของ​ตัวเอง แต่​พอ​แยก​เสร็จ​ก็​โผล่​คำถาม​ใหม่​ทันที: แล้ว​สี่​ก้อน​นี้​จะ​คุย​กัน​ยังไง โดย​ไม่​กลับ​ไป​พัน​กัน​เป็น​ก้อน​โคลน​เหมือน​เดิม? เครื่องมือ​ที่​ตอบ​คำถาม​นี้​คือ Context MapContext Mapแผนภาพ​ที่​วาด​ทุก Bounded Context และ​ความ​สัมพันธ์​ระหว่าง​กัน (Partnership, Customer-Supplier, ACL ฯลฯ) ให้​เห็น​ภาพ​รวม​ว่า​ใคร​เชื่อม​กับ​ใคร และ​ใคร​มี​อำนาจ​กำหนด model มากกว่า (upstream/downstream)Strategic Design — แผนที่​ที่​วาด​ทุก context เป็นกล่อง แล้ว​ลาก​เส้น​ระหว่าง​กล่อง​พร้อม​ติด​ป้าย​ว่า​ความ​สัมพันธ์​คู่​นั้น เป็น​แบบ​ไหน และ ใคร​มี​อำนาจ​กำหนด model มากกว่า

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — คอร์ส​นี้​ต่อยอด domain Order เดิม​ให้​กลาย​เป็น modular monolith 4 module บท​นี้​ยัง​เน้น​แนวคิด​เชิงกลยุทธ์ (strategic design) แต่​เริ่ม​แตะ​รูปร่าง code ของ integration event และ adapter ที่​จะ​ได้​ลงมือจริงในบทถัดๆ ไป

ต้นน้ำ-ปลาย​น้ำ: ทิศ​ของ​อำนาจ​ก่อน​จะ​พูด​ถึง​แบบ​ความ​สัมพันธ์

หัวข้อ​ที่​มีชื่อ​ว่า “ต้นน้ำ-ปลาย​น้ำ: ทิศ​ของ​อำนาจ​ก่อน​จะ​พูด​ถึง​แบบ​ความ​สัมพันธ์”

ก่อน​จะ​ไล่​ชื่อ​แบบ​ความ​สัมพันธ์ ต้อง​เข้าใจ​แกน​ที่​อยู่​เบื้องหลัง​ทุก​แบบ​ก่อน นั่น​คือ ทิศ​ของ​อำนาจ ใน​คู่ context สอง​อัน​ที่​พึ่งพา​กัน มัก​มี​ฝ่าย​หนึ่ง​เป็น ต้นน้ำ (upstream) และ​อีก​ฝ่าย​เป็น ปลาย​น้ำ (downstream)

  • ต้นน้ำ (upstream) คือ​ฝ่าย​ที่​การ​เปลี่ยนแปลง​ของ​มัน ไหล​ลง ไป​กระทบ​อีก​ฝ่าย ต้นน้ำ​เปลี่ยน contract เมื่อไร ปลาย​น้ำ​ต้อง​ปรับ​ตาม
  • ปลาย​น้ำ (downstream) คือ​ฝ่าย​ที่​ต้อง​รับ​ผล​จาก​ต้นน้ำ ปลาย​น้ำ​เปลี่ยน​ภายใน​ตัวเอง​ได้​อิสระ แต่​เปลี่ยน​แล้ว ไม่​กระเทือน ต้นน้ำ

จุด​ที่​คน​พลาด​บ่อย​ที่สุด​คือ​คิด​ว่า “หัว​ลูกศร​บน​แผนที่​ชี้​ไป​ทาง​ไหน ฝ่าย​นั้น​คือ​ปลาย​น้ำ” — ไม่​จริง​เสมอ​ไป ทิศ​ของ​อำนาจ​กับ​ทิศ​ที่ code เรียกหา​กัน​เป็น​คนละ​เรื่อง โดย​เฉพาะ​เวลา​เรา​ต้อง​ต่อ​กับ​ของ​ภายนอก เช่น Ordering เป็น​คน​กด​เรียก payment gateway (code วิ่ง​จาก Ordering ออก​ไป) แต่ gateway ต่างหาก​ที่​เป็นต้น​น้ำ เพราะ​มัน​เป็น​คน​กำหนด contract ที่​เรา​แก้​ไม่​ได้ Ordering จึง​เป็น​ปลาย​น้ำ​ที่​ต้อง​ปรับ​ตัว​ตาม เพราะ​เหตุ​นี้​เรา​ถึง เขียน​ป้าย upstream/downstream กำกับไว้ตรงๆ บน​แผนที่ แทนที่​จะ​ให้​คน​อ่าน​เดา​จาก​หัว​ลูกศร

แบบ​ความ​สัมพันธ์​เจ็ด​แบบ​ที่​จะ​เล่า​ต่อ​ไป​นี้ ต่าง​กัน​ที่ ปลาย​น้ำ​จัดการ​กับ​อำนาจ​ของ​ต้นน้ำ​อย่างไร — ยอม​ตาม​ทั้งดุ้น, ต่อรอง​ให้​ต้นน้ำ​แคร์​เรา, หรือ​สร้าง​กำแพง​กัน​ไม่​ให้ model ต้นน้ำ​รั่ว​เข้า​มา

แต่ละ​แบบ​มี​คำ​อธิ​บายสั้นๆ และ​ตัวอย่าง​จากฟู้ด​เดลิ​เวอรี​หนึ่ง​บรรทัด ให้​เห็น​ภาพ​ว่า​เวลา​จะ​ใช้​แบบ​ไหน

  1. PartnershipPartnershipความ​สัมพันธ์​ที่2 context ต้อง​ประสาน​แผนงาน​ร่วม​กัน​เพราะ​พึ่งพา​กัน​สอง​ทาง หาก​ฝ่าย​ใด​ล้มเหลว​อีก​ฝ่าย​ก็​ไป​ต่อ​ไม่​ได้ จึง​ต้อง​ปล่อย feature ที่​เกี่ยวข้อง​กัน​พร้อม​กัน​เสมอStrategic Design — 2 context พึ่งพา​กัน​สอง​ทาง ต้อง​วางแผน​และ​ปล่อย​ของ พร้อม​กัน ถ้า​ฝ่าย​หนึ่ง​พัง อีก​ฝ่าย​ก็​ไป​ต่อ​ไม่​ได้ ไม่มี​ใคร​เป็นต้น​น้ำชัดๆ ทั้ง​คู่​ต้อง​ประสาน​งาน​กัน​ตลอด ตัวอย่าง: ถ้า​จะ​ทำ feature “ครัว​กด​ว่า​ใกล้​เสร็จ เพื่อ​ให้​ระบบ​เรียก​ไร​เด​อร์มา​รอ​ล่วงหน้า” — Kitchen กับ Delivery ต้อง​ล็อก spec ร่วม​และ​ปล่อย​รุ่น​พร้อม​กัน มิ​ฉะนั้น feature ครึ่งๆ กลางๆ จะ​พัง​ทั้ง​คู่

  2. Customer-SupplierCustomer-Supplierความ​สัมพันธ์​ต้นน้ำ-ปลาย​น้ำ​ที่​ทีม​ต้นน้ำ (supplier) รับ​ความ​ต้องการ​ของ​ทีม​ปลาย​น้ำ (customer) เข้าไป​วางแผน​ด้วย เช่น Ordering (supplier) ออกแบบ event ให้ Kitchen (customer) ใช้ได้​จริงStrategic Design — ความ​สัมพันธ์​ต้นน้ำ-ปลาย​น้ำ​ที่​ต้นน้ำ (supplier) ยอมรับ​ความ​ต้องการ ของ​ปลาย​น้ำ (customer) เข้าไป​ใน​แผนงาน​ของ​ตัวเอง ปลาย​น้ำ​ร้องขอ​ได้ ต้นน้ำ​รับ​ไป​ทำให้ — คนละ​เรื่อง​กับ conformist ที่​ปลาย​น้ำ​ร้องขอ​อะไร​ไม่​ได้​เลย ตัวอย่าง: Ordering (supplier) ออกแบบ OrderConfirmedIntegrationEvent ให้​แนบ​ข้อมูล​พอที่ Kitchen (customer) จะ​เปิด​ตั๋ว​คิว​ได้​จริง — Kitchen บอก​ได้​ว่า​อยาก​ได้ field ไหน แล้ว Ordering รับ​ไป​ใส่​ให้

  3. ConformistConformistทีม​ปลาย​น้ำ​ยอม 'ตามใจ' model ของ​ทีม​ต้นน้ำ​ทั้งหมด​โดย​ไม่​แปล​ภาษา ใช้​เมื่อ​ทีม​ต้นน้ำ​ไม่มี​แรง​จูงใจ​จะ​รองรับ​เรา แลก​กับ​ความ​เสี่ยง​ที่ model ภายนอก​จะ​รั่ว​เข้า​มาStrategic Design — ปลาย​น้ำ ยอม​ตาม model ของ​ต้นน้ำ​ทั้งดุ้น ไม่​แปล ไม่​ต่อรอง ใช้​เมื่อ​ต้นน้ำ​ไม่มี​แรง​จูงใจ​จะ​แคร์​เรา แลก​ความ​ง่าย​กับ​ความ​เสี่ยง​ที่ model ของ​ต้นน้ำ​จะ​ไหล​เข้า​มา​ปน​ใน​บ้าน​เรา ตัวอย่าง: ถ้า Delivery ต้อง​ดึง​พิกัด​จาก​ผู้​ให้​บริการ​แผนที่​ภายนอก​ราย​ใหญ่​ที่​ไม่มี​ทาง​แก้ API ให้​เรา Delivery ก็​ใช้ field ตาม​ที่​เขา​ให้​มา​เป๊ะๆ ยอม​เป็น conformist เพราะ​สู้​ไม่​ไหว

  4. Anti-Corruption LayerAnti-Corruption Layer (ACL)ชั้น​แปล​ภาษา/model ที่​กั้น​ระหว่าง context สอง​อัน ไม่​ให้ model ของ​อีก​ฝ่าย​รั่ว​เข้า​มา​ปน​เปื้อน เช่น PaymentGatewayAdapter ที่​แปล Money/Order ของ Ordering เป็น DTO ของ​ผู้​ให้​บริการ​ชำระ​เงิน​ภายนอกStrategic Design — ปลาย​น้ำ​สร้าง ชั้น​แปล (adapter) กั้น​ระหว่าง​ตัวเอง​กับ​ต้นน้ำ ไม่​ให้ type และ model ของ​อีก​ฝ่าย​รั่ว​เข้า​มา​ถึงใจ​กลาง domain ต่าง​จาก conformist ตรง​ที่​เรา​ยอม​ลงแรง​สร้าง​กำแพง​เพื่อ รักษา ความ​สะอาด​ของ model ตัวเอง​ไว้ ตัวอย่าง: Ordering คุย​กับ payment gateway ภายนอก​ผ่าน PaymentGatewayAdapter ที่​แปล Money.Thb เป็น DTO ของ​ผู้​ให้​บริการ แล้ว​แปล​ผลกลับ — code domain ของ Ordering ไม่​เคย​เห็น type ของ gateway เลย​สัก​ตัว

  5. Open Host ServiceOpen Host Service (OHS)ทีม​ต้นน้ำ​เปิด​โพรโทคอล/API ที่​นิยาม​ไว้​ชัดเจน​ให้​ผู้บริโภค​หลาย​ฝ่าย​เชื่อม​ต่อ​ได้​โดย​ไม่​ต้อง​เจรจา​ที​ละ​คู่ เหมาะ​เมื่อ​มี​หลาย context ต้อง​พึ่งพา service เดียวกันStrategic Design — ต้นน้ำ​เปิด API/โพรโทคอล​สาธารณะ​ที่​นิยาม​ไว้​ชัด ให้​ผู้บริโภค หลาย​ฝ่าย ต่อ​ได้​โดย​ไม่​ต้อง​เจรจา​ที​ละ​คู่ เหมาะ​เมื่อ​มี​หลาย context ต้อง​พึ่งพา service เดียวกัน ตัวอย่าง: ถ้า​วัน​หน้า​ทั้ง Kitchen, Delivery และ​ระบบ​วิเคราะห์​ยอด​ขาย​อยาก​รู้​ว่า​ออเดอร์​ถูก​ยืนยัน​เมื่อไร Ordering ก็​เปิด​ช่อง​ทาง publish OrderConfirmed แบบ​เดียว​ให้​ทุก​ฝ่าย subscribe เป็นกลุ่ม แทนที่​จะ​ทำ integration แยก​ที​ละ​คู่

  6. Published LanguagePublished Languageภาษา​กลาง​ที่​เอกสาร​ไว้​ชัดเจน​สำหรับ​แลกเปลี่ยน​ข้อมูล​ข้าม context เช่น สัญญา (contract) ของ integration event อย่าง FoodReadyIntegrationEvent มัก​มา​คู่​กับ Open Host ServiceStrategic Design — ภาษา​กลาง​ที่ เอกสาร​ไว้​ชัด สำหรับ​แลก​ข้อมูล​ข้าม context คือ schema ของ Integration EventIntegration Eventevent ที่​สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่​มี​แต่ id/ค่า​พื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่าง​จาก Domain Event ที่​อยู่​ใน​โพรเซส​เดียวArchitecture ที่​ทุก​ฝ่าย​ผูก​ด้วย มัก​มา​คู่​กับ open host service (OHS เปิด​ประตู, published language คือ​รูปแบบ​ข้อความ​ที่​ไหล​ผ่าน​ประตู​นั้น) ตัวอย่าง: FoodReadyIntegrationEvent(Guid OrderId, Guid TicketId) คือ​สัญญา​ที่ Kitchen ประกาศ​ออก​มา ใคร​จะ​ฟัง​ก็​ผูก​กับ schema นี้ ไม่ใช่​กับ type Ticket ภายใน​ของ Kitchen

  7. Separate WaysSeparate Waysตัดสิน​ใจ​ไม่​เชื่อม2 context เข้า​ด้วย​กัน​เลย เพราะ​ต้นทุน​การ​เชื่อม​ต่อ​ไม่​คุ้ม​กับ​ประโยชน์ ปล่อย​ให้​แต่ละ​ฝ่าย​แก้​ปัญหา​ของ​ตัวเอง​แบบ​ง่าย ๆStrategic Design — ตัดสิน​ใจ ไม่​เชื่อม 2 context เข้า​ด้วย​กัน​เลย เพราะ​ต้นทุน​การ​ต่อ​ไม่​คุ้ม​กับ​ประโยชน์ ปล่อย​ให้​แต่ละ​ฝ่าย​แก้​ปัญหา​ของ​ตัวเอง​แบบ​ตรง​ไป​ตรง​มา ตัวอย่าง: Payment ไม่มี​เหตุผล​ต้อง​รู้เรื่อง​สเตชัน​ครัว​หรือ​คิว​ทำ​อาหาร​เลย — 2 context นี้​จึง​ไม่​ลาก​เส้น​ถึงกัน แยก​ทาง​กัน​เดิน ไม่​ต้อง​ฝืน​เชื่อม

พอ​มี​คำ​ศัพท์​ครบ​แล้ว ก็​ลงมือ​วาด​ของ​จริง​ได้ คอร์ส​นี้​มี4 context แต่​เส้น​ความ​สัมพันธ์​ที่​เป็น​สาย​หลัก​ของออเดอร์​มี​สาม​เส้น — จาก​กด​สั่ง ไป​ครัว ไป​ถึง​มือ​ลูกค้า และ​แยก​ออก​ไป​หา​เรื่อง​เงิน

flowchart LR
  ORD["Ordering · core<br/>Order, PlaceOrder"]
  KIT["Kitchen · supporting<br/>Ticket, MarkReady"]
  DEL["Delivery · supporting<br/>Assignment, AssignRider"]
  PAY["Payment · generic<br/>gateway ภายนอก"]

  ORD -->|"Customer-Supplier<br/>Ordering = ต้นน้ำ · Kitchen = ปลายน้ำ<br/>OrderConfirmedIntegrationEvent"| KIT
  KIT -->|"Published Language<br/>Kitchen = ต้นน้ำ · Delivery = ปลายน้ำ<br/>FoodReadyIntegrationEvent"| DEL
  ORD -->|"Anti-Corruption Layer<br/>Payment = ต้นน้ำภายนอก · Ordering = ปลายน้ำ<br/>Ordering ห่อ gateway ด้วย adapter"| PAY

  classDef core fill:#c7d2fe,stroke:#3730a3,color:#1a1a1f;
  classDef supp fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
  classDef gen fill:#fde68a,stroke:#92400e,color:#1a1a1f;
  class ORD core;
  class KIT,DEL supp;
  class PAY gen;

คำ​บรรยาย​ภาพ: แผนที่ context ของฟู้ด​เดลิ​เวอรี — Ordering (น้ำเงิน, core) เป็น​ศูนย์กลาง ยิง​เหตุการณ์​ไป​ยัง Kitchen (เขียว, supporting) แบบ customer-supplier แล้ว Kitchen ยิง​ต่อ​ไป Delivery (เขียว) ด้วย published language ส่วน​เส้น​ลง​ล่าง​ไป Payment (เหลือง, generic) เป็น anti-corruption layer ป้าย​บน​เส้น​บอก​ทั้ง แบบ​ความ​สัมพันธ์ และ ใคร​ต้นน้ำ​ใคร​ปลาย​น้ำ — สังเกต​ว่า​เส้น​ไป Payment หัว​ลูกศร​ชี้​จาก Ordering ออก​ไป (Ordering เป็น​คน​เรียก) แต่​ป้าย​บอกว่า Payment ต่างหาก​คือ​ต้นน้ำ นี่​คือ​จุด​ที่​ทิศ​ลูกศร​กับ​ทิศ​อำนาจ​สวน​กัน

เมื่อ​ลูกค้า​จ่าย​เงิน​และ​ออเดอร์​ถูก​ยืนยัน Ordering จะ​ปล่อย OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ออก​มา Kitchen รับ​ไป​เปิด Ticket ใหม่​เข้า​คิว​ทำ​อาหาร ความ​สัมพันธ์​นี้​เป็น customer-supplier เพราะ Ordering (ต้นน้ำ/supplier) กับ Kitchen (ปลาย​น้ำ/customer) เป็น​ทีม​ใน​บริษัท​เดียวกัน​ที่​คุย​กัน​ได้ — Kitchen ร้องขอ​ได้​ว่า event ต้อง​แนบ​ข้อมูล​อะไร​บ้าง​จึง​จะ​เปิด​ตั๋ว​ได้​ครบ แล้ว Ordering รับ​ความ​ต้องการ​นั้น​เข้าไป​ออกแบบ contract ให้ ไม่ใช่​โยน event หน้าตา​อะไร​ก็ได้​มา​ให้ Kitchen เดา​เอง

ทำไม​ไม่ใช่ conformist? เพราะ conformist คือ​ปลาย​น้ำ​ที่ ร้องขอ​อะไร​ไม่​ได้​เลย ต้อง​กลืน model ต้นน้ำ​ทั้งดุ้น แต่​ที่​นี่ Kitchen มี​ปาก​มี​เสียง Ordering ฟัง — จึง​เป็น customer-supplier ที่​เป็น​ธรรม​กว่า

พอ​อาหาร​ทำ​เสร็จ Kitchen ปล่อย FoodReadyIntegrationEvent(Guid OrderId, Guid TicketId) Delivery รับ​ไป​สร้าง Assignment แล้ว​จับ​คู่​ไร​เด​อร์ ความ​สัมพันธ์​นี้​เป็น published language เพราะ​สิ่ง​ที่​ผูก​สอง​ฝ่าย​ไว้​ไม่ใช่​การ​คุย​กัน​เป็น​รายๆ แต่​คือ schema ของ event ที่​ประกาศ​ไว้​ชัด — Kitchen เผยแพร่​รูปแบบ​ข้อความ​นี้​ออก​มา​เป็น​สัญญา​สาธารณะ ใคร​จะ​ฟัง​ก็​ผูก​กับ schema ไม่ใช่​ผูก​กับ type Ticket ภายใน​ของ Kitchen ถ้า​พรุ่งนี้​มี​ระบบ​แจ้ง​เตือน​ลูกค้า​อยาก​ฟัง “อาหาร​พร้อม​แล้ว” ด้วย ก็​แค่ subscribe event เดิม ไม่​ต้อง​แก้​อะไร​ที่ Kitchen เลย นี่​คือ​ข้อดี​ของ​การ​สื่อสาร​ผ่าน​ภาษา​ที่​เผยแพร่​ไว้​แทน​การ​ผูก​ตรง​กับ model ภายใน

จุด​สำคัญ​ที่​ต้อง​ย้ำ: FoodReadyIntegrationEvent เป็น integration event ไม่ใช่ Domain EventDomain Eventสิ่ง​ที่​เกิด​ขึ้น​แล้ว​ภายใน context เดียว ทำงาน in-process และ​อ้างอิง domain object ได้​ตรง ๆ เช่น OrderConfirmed ภายใน Ordering — เมื่อ​จะ​ส่ง​ข้าม​ขอบเขต context ต้อง​แปลง​เป็น Integration Event ก่อน​เสมอ ห้าม​ส่ง domain event ข้าม​ไป​ตรง ๆTactical Design — มัน​ข้าม​ขอบเขต context จึง​พก แค่ id กับ​ค่า​พื้นฐาน (Guid OrderId, Guid TicketId) ห้าม​แนบ Ticket ทั้ง​ก้อน​ซึ่ง​เป็น domain object ของ Kitchen ออก​ไป เพราะ​นั่น​เท่ากับ​รื้อ​กำแพง​ที่​เรา​เพิ่ง​สร้าง domain event อย่าง TicketReady อยู่​ใน​บ้าน Kitchen เท่านั้น พอ​จะ​ข้าม​ออก​ไป​หา Delivery ต้อง​แปลง​เป็น integration event ก่อน​เสมอ

Payment เป็น subdomain แบบ generic — เรา​ไม่​ปั้น payment gateway เอง แต่​ไป​ต่อ​กับ​ผู้​ให้​บริการ​ภายนอก model ของ​ผู้​ให้​บริการ​ราย​นั้น​เรา​แก้​ไม่​ได้ มัน​จึง​เป็น ต้นน้ำ เต็ม​ตัว ถ้า​ปล่อย​ให้ DTO ของ gateway ไหล​เข้า​มา​ถึงใจ​กลาง Ordering วัน​หนึ่ง​ที่​ผู้​ให้​บริการ​เปลี่ยน field หรือ​เรา​ย้าย​ไป​เจ้า​อื่น domain ทั้ง​ก้อน​จะ​พัง​ตาม เพราะ​เหตุ​นี้ Ordering (ปลาย​น้ำ) จึง​ต้อง​สร้าง anti-corruption layer — พูด​กับ gateway ผ่าน port ของ​ตัวเอง​แล้ว​ให้ adapter เป็น​คน​แปล

เริ่ม​จาก version ที่​ยัง​ไม่มี​กำแพง:

❌ version ดิบ: DTO ของ​ผู้​ให้​บริการ​รั่ว​เข้า​ถึงใจ​กลาง domain
public class ConfirmOrderHandler
{
private readonly AcmePayClient _acme; // type ของผู้ให้บริการภายนอกโผล่ใน domain
public async Task Handle(Order order, CancellationToken ct)
{
// รูปร่างคำขอของ AcmePay ผูกติดกับตรรกะของ Ordering โดยตรง
var req = new AcmeChargeRequest
{
reference = order.Id.Value.ToString(),
amountMinor = (long)(order.Total.Amount * 100), // ภาษาของ AcmePay ไม่ใช่ของเรา
currencyCode = "THB",
};
await _acme.ChargeAsync(req, ct);
// ย้ายเจ้าเมื่อไร หรือ AcmePay เปลี่ยน schema เมื่อไร ตรงนี้พังทันที
}
}

แล้ว​ห่อ​ด้วย ACL — Ordering เห็น​แค่ port ที่​พูด​ภาษา​ของ​ตัวเอง ส่วน adapter ที่​แปล​ไป-กลับ​อยู่ นอก domain (port IPaymentGateway version คอร์ส​นี้​ต่อยอด​ของ​ไตร​ภาค​ที่​เป็น​แบบ fire-and-forget ให้​คืน PaymentResult กลับ​มา เพื่อ​ให้ ACL แปล​คำ​ตอบ​ของ gateway กลับ​เข้า domain ได้ — บท 5 เจาะ​รายละเอียด):

✅ ACL: Ordering เห็น​แค่ port ของ​ตัวเอง model gateway ไม่​รั่ว​เข้า​มา
// port ที่ Ordering เป็นเจ้าของ พูดด้วย Money.Thb / OrderId ของ Ordering เอง
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(OrderId orderId, Money amount, CancellationToken ct);
Task<PaymentResult> RefundAsync(OrderId orderId, Money amount, CancellationToken ct);
}
public record PaymentResult(bool Captured, string GatewayReference);
// adapter = ตัว ACL: แปล Money.Thb <-> DTO ของ AcmePay อยู่ที่ชั้น infrastructure
public sealed class PaymentGatewayAdapter : IPaymentGateway
{
private readonly AcmePayClient _acme;
public PaymentGatewayAdapter(AcmePayClient acme) => _acme = acme;
public async Task<PaymentResult> ChargeAsync(OrderId orderId, Money amount, CancellationToken ct)
{
var req = new AcmeChargeRequest { reference = orderId.Value.ToString(), amountMinor = (long)(amount.Amount * 100), currencyCode = "THB" };
var res = await _acme.ChargeAsync(req, ct); // ภาษาของ AcmePay ตายอยู่ในนี้
return new PaymentResult(res.status == "SUCCESS", res.txnId);
}
// RefundAsync แปลทำนองเดียวกัน
}

ตอน​นี้​ถ้า​ย้าย​ไป​ผู้​ให้​บริการ​เจ้า​อื่น เรา​เขียน adapter ตัว​ใหม่​ที่ implement IPaymentGateway เหมือน​เดิม โดย​ไม่​แตะ code domain ของ Ordering แม้แต่​บรรทัด​เดียว — นั่น​คือ​คุณค่า​ของ ACL: มัน​ดูด​แรง​กระแทก​จาก​การ​เปลี่ยนแปลง​ของ​ต้นน้ำ​ภายนอก​ไว้​ที่​ชั้น​เดียว

รูปร่าง​ของ integration event และ bus ที่​วิ่ง​ข้าม​ขอบเขต​ใน​ระบบ​นี้​หน้าตา​แบบ​นี้ — ทุก​ตัว​มี​แต่ id/ค่า​พื้นฐาน ไม่มี domain object แม้แต่​ตัว​เดียว:

Published Language: สัญญา​ข้าม​ขอบเขต — id/primitive เท่านั้น
public interface IIntegrationEvent { }
// Ordering ประกาศ (customer-supplier: Ordering = ต้นน้ำ)
public record OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) : IIntegrationEvent;
// Kitchen ประกาศ (published language ให้ Delivery และคนอื่นฟัง)
public record FoodReadyIntegrationEvent(Guid OrderId, Guid TicketId) : IIntegrationEvent;
// Delivery ประกาศ ต่อสายให้ปลายทางฟัง
public record OrderDeliveredIntegrationEvent(Guid OrderId, Guid AssignmentId) : IIntegrationEvent;
public interface IIntegrationEventBus
{
Task PublishAsync(IIntegrationEvent e, CancellationToken ct);
}

แล้ว​อีก​สี่​แบบ​ที่​ไม่​ได้​อยู่​บน​แผนที่​ล่ะ?

หัวข้อ​ที่​มีชื่อ​ว่า “แล้ว​อีก​สี่​แบบ​ที่​ไม่​ได้​อยู่​บน​แผนที่​ล่ะ?”

เรา​ใช้​จริง​แค่​สาม​แบบ​จาก​เจ็ด — customer-supplier, published language, ACL — แต่​ที่​ต้อง​รู้จัก​ทั้ง​เจ็ด​เพราะ การ​เลือก​ไม่​ใช้​ก็​เป็นการ​ตัดสิน​ใจ ที่​ต้อง​อธิบาย​ได้:

  • partnership ไม่​ได้​ใช้​เพราะ​เรา​จงใจ​ออกแบบ​ให้​แต่ละ context ปล่อย​ของ​ได้​อิสระ ไม่​ต้อง​ล็อก spec ปล่อย​พร้อม​กัน การ​สื่อสาร​ผ่าน integration event ทำให้​ต่าง​ฝ่าย​ต่าง​เดิน​ได้​เอง
  • open host service ยัง​ไม่​จำเป็น​ตอน​นี้​เพราะ​แต่ละ event มี​ผู้​ฟัง​หลัก​ฝ่าย​เดียว แต่​ถ้า​ผู้​ฟัง​เพิ่ม​ขึ้น​หลาย​ฝ่าย Ordering ควร​ยก​ระดับ​ช่อง​ทาง OrderConfirmed ขึ้น​เป็น OHS เต็ม​ตัว
  • conformist เรา​เลี่ยง​ตรง​จุด Payment โดย​ยอม​ลงแรง​ทำ ACL แทน — เพราะ model เรื่อง​เงิน​สำคัญ​เกิน​กว่า​จะ​ปล่อย​ให้​ภาษา​ของ gateway ภายนอก​ไหล​เข้า​มา​ปน
  • separate ways คือ​ความ​สัมพันธ์​ระหว่าง Payment กับ Kitchen/Delivery ที่​เรา​จงใจ ไม่​ลาก​เส้น เพราะ​ไม่มี​เหตุผล​ทาง​ธุรกิจ​ให้​เชื่อม

พอ​ทุก​เส้น​บน​แผนที่​มี​เหตุผล​กำกับ — ทั้ง​เส้น​ที่​ลาก​และ​เส้น​ที่​จงใจ​ไม่​ลาก — context map ก็​กลาย​เป็น​เอกสาร​ที่​บอก​ได้​ว่า​ระบบ​นี้​ถูก​ออกแบบ​มา​ให้​เปลี่ยน​ตรง​ไหน​ได้​โดย​ไม่​กระเทือน​ที่​อื่น

context map ไม่ใช่​แค่​กล่อง​กับ​ลูกศร​สวยๆ แต่​คือ​แผนที่​อำนาจ — มัน​บอกว่า​ใคร​กำหนด model ให้​ใคร (upstream/downstream) และ​ปลาย​น้ำ​แต่ละ​คู่​เลือก​รับมือ​กับ​อำนาจ​นั้น​ด้วย​วิธี​ไหน สำหรับฟู้ด​เดลิ​เวอรี​ของ​เรา สาย​หลัก​คือ Ordering ป้อน Kitchen แบบ customer-supplier, Kitchen ป้อน Delivery ด้วย published language, และ Ordering กันตัวเอง​จาก payment gateway ภายนอก​ด้วย ACL

บท​ถัด​ไป​เรา​จะ​ซูม​เข้าไป​ที่​ตัว event เหล่า​นี้​ให้​ลึก​ขึ้น — ความ​ต่าง​ระหว่าง domain event กับ integration event, ทำไม​ส่ง domain object ข้าม​ขอบเขต​ไม่​ได้, และ​จะ​แปลง​จาก​อัน​หนึ่ง​เป็น​อีก​อันตรง​ไหน​ของ code เพราะ​เส้น​บน​แผนที่​ที่​เรา​เพิ่ง​วาด จะ​กลาย​เป็น​ข้อความ​จริง​ที่​วิ่ง​ข้าม​ขอบเขต​ใน​บท​ต่อๆ ไป


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

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

  • Context Map — แผนภาพ​ความ​สัมพันธ์​ระหว่าง bounded context และ​แบบ​ความ​สัมพันธ์​ทั้งหมด หัวใจ​ของ​ทั้ง​บท
  • Anti-Corruption Layer — ชั้น​แปล​ที่​กัน model ของ​ต้นน้ำ​ไม่​ให้​รั่ว​เข้า​มา เหมือน​ที่ Ordering ใช้​กับ payment gateway
  • Bounded Context — ขอบเขต​ที่ model และ​ภาษา​หนึ่ง​ชุด​คง​ความหมาย​ไว้ สิ่ง​ที่​เส้น​บน​แผนที่​เชื่อม​ถึงกัน

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

ข้อ 1 / 3

Kitchen ประกาศ schema ของ FoodReadyIntegrationEvent(Guid OrderId, Guid TicketId) ออกมาเป็นสัญญาสาธารณะ ใครจะฟังก็ผูกกับ schema นี้ ไม่ใช่กับ type Ticket ภายในของ Kitchen — นี่คือความสัมพันธ์แบบใด?