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

เมื่อไร​ควร (และ​ไม่​ควร) แตก​เป็น service

ตลอด​คอร์ส​นี้​เรา​แยก domain ฟู้ด​เดลิ​เวอรี​ออก​เป็น4 context — Ordering, Kitchen, Delivery, Payment — วาด context map ให้​เห็น​ว่า​ใคร​เชื่อม​กับ​ใคร แล้ว​เชื่อม​ทุก​อัน​กลับ​เข้า​ด้วย​กัน​อย่าง​เชื่อถือ​ได้​ด้วย integration event กับ transactional outbox แต่​ทั้งหมด​นั้น​ยัง​รัน​อยู่​ใน​โพรเซส​เดียวกัน เป็น Modular MonolithModular Monolithสถาปัตยกรรม​ที่​ทุก context เป็น module/assembly แยก​กัน​ชัดเจน​แต่​รัน​ใน​โพรเซส​เดียวกัน แต่ละ module เป็น​เจ้าของ model/schema ของ​ตัวเอง ห้าม​อ้างอิง domain type ข้าม module ตรง ๆ เชื่อม​ได้​เฉพาะ​ผ่าน integration event หรือ query ที่​แปล​แล้วArchitecture — ทุก context เป็น module แยก​กัน​ชัด แต่ deploy พร้อม​กัน​ทั้ง​ก้อน คำถาม​ของ​บท​นี้​คือ​คำถาม​ที่​ทีม​มัก​ถาม​เร็ว​เกิน​ไป: เมื่อไร​เรา​ถึง​ควร​หยิบ context หนึ่ง​ออก​มา​แตก​เป็น service ที่ deploy แยกจริงๆ — และ​เมื่อไร​ที่​ไม่​ควร?

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) บท​นี้​เป็น​ภาค​ตัดสิน​ใจ (decision) มากกว่า​ภาค​ลงมือ — เรา​จะ​ชั่ง​น้ำหนัก​ว่า​เมื่อไร​ควร​แตก โดย​มี​ตัวอย่าง code สั้นๆ ให้​เห็น​ว่า “รอย​ต่อ” ที่​ทำให้​แตก​ได้​ถูก​ที่​ตรง​ไหน ส่วน​การ​แตก context จริง​ลงมือ​กัน​ใน​บท​สุดท้าย

ก่อน​จะ​พูด​ถึง​การ​แตก ต้อง​เห็น​ก่อน​ว่าการ ไม่​แตก ให้​อะไร​เรา​มา​บ้าง เพราะ​ทุก​อย่าง​ใน list นี้​คือ​สิ่ง​ที่​เรา​จะ​เสีย​ไป​ตอน​แตก:

  • in-process call — ตอน Ordering ยืนยัน​ออเดอร์​แล้ว​อยาก​บอก Kitchen ให้​เปิด​ตั๋ว มัน​คือ​การ​เรียก function ใน​โพรเซส​เดียวกัน เร็ว​ระดับ​นาโน​วินาที ไม่มี network เข้า​มา​เกี่ยว ไม่มี​อะไร​ให้​ล้ม​ระหว่าง​ทาง
  • deploy ก้อน​เดียว — build เดียว artifact เดียว ปล่อย​ขึ้น​ที​เดียว​จบ ไม่มี​ปัญหา “version ของ Kitchen กับ Ordering ไม่​เข้า​กัน” เพราะ​มัน​มา​จาก commit เดียวกัน​เสมอ
  • transaction เดียว — เขียน​ออเดอร์​ลง DB กับ​บันทึก​งาน​ที่​ต้อง​ทำ​ต่อ อยู่​ใน transaction เดียวกัน​ได้ ถ้า​อะไร​พัง rollback พร้อม​กัน​ทั้งหมด ข้อมูล​ไม่มี​ทาง​ค้างครึ่งๆ กลางๆ
  • ops เรียบ​ง่าย — มี process เดียว​ให้​ดูแล log เดียว​ให้​ไล่ deploy pipeline เดียว ไม่​ต้อง​มี infra สำหรับ​ส่ง​ข้อความ​ข้าม​เครื่อง

นี่​ไม่ใช่ “หนี้” ที่​เรา​ต้อง​รีบ​ใช้​คืน — มัน​คือ ข้อ​ได้​เปรียบจริงๆ ของ modular monolith ที่​ออกแบบ​ดี ตราบ​ใด​ที่​ขอบเขต​ระหว่าง module ยัง​คม (แต่ละ context เป็น​เจ้าของ schema ตัวเอง คุย​กัน​ผ่าน integration event เท่านั้น ไม่​อ้าง type ข้าม module ตรงๆ) เรา​ก็ได้​ความ​ชัดเจน​ของ context ครบ​โดย​ไม่​ต้อง​จ่าย​ต้นทุน​ของ​ระบบ​กระจาย

การ​แตก context ออก​เป็น service ที่ deploy แยก ซื้อ ของ​สี่​อย่าง​มา​ได้ — scale ได้​อิสระ (ถ้า Kitchen รับ​โหลด​หนัก​ช่วง​เที่ยง ก็​เพิ่ม​เครื่อง​เฉพาะ Kitchen ได้​โดย​ไม่​แตะ​ตัว​อื่น), deploy ได้​อิสระ, ให้​ทีม​หนึ่ง​เป็น​เจ้าของ​เต็ม​ตัว, และ​ปล่อย​ของ​ตาม​จังหวะ​ของ​ตัวเอง​ได้ แต่​ของ​พวก​นี้​ไม่​ได้​มา​ฟรี ทันที​ที่ context ข้าม​เส้น process ออก​ไป เรา​ต้อง จ่าย สี่​อย่าง​นี้​แทน:

  • network latency — in-process call ที่​เคย​เร็ว​ระดับ​นาโน​วินาที กลาย​เป็นการ​ยิง​ข้าม network เป็น​มิลลิ​วินาที และ​ช้า​ไม่​คงที่ ทุก​ครั้ง​ที่ Ordering ต้อง​รู้​อะไร​จาก Kitchen คือ​การ​เดิน​ทางข้าม​สาย
  • partial failure — ใน​โพรเซส​เดียว ถ้า code รัน มัน​รัน​ทั้งหมด แต่​พอ​แยก​เครื่อง Kitchen อาจ​ล่ม​ขณะ​ที่ Ordering ยัง​อยู่ เรา​ต้อง​ออกแบบ​เผื่อ​กรณี “อีก​ฝ่าย​ไม่​ตอบ” ทุก​จุด — retry, timeout, circuit breaker กลาย​เป็น​เรื่อง​ที่​เลี่ยง​ไม่​ได้
  • eventual consistency — ไม่มี transaction เดียว​ครอบ​ข้าม service ได้​อีก​แล้ว ตอน Ordering ยืนยัน​ออเดอร์ Kitchen จะ​รู้ “อีก​สัก​ครู่” ไม่ใช่ “ทันที” ช่วง​เวลาสั้นๆ ที่​สอง​ฝั่ง​เห็น​ไม่​ตรง​กัน​เป็น​เรื่อง​ปกติ​ที่​ต้อง​ยอมรับ​และ​ออกแบบ​รองรับ
  • ops overhead — จาก1 process กลาย​เป็น​หลาย service ที่​ต้อง deploy, monitor, และ trace แยก​กัน บวก infra ใหม่​สำหรับ​ส่ง​ข้อความ​ข้าม​เครื่อง (เรื่อง​นี้​อยู่​หัวข้อ​ถัด​ไป) ทุก​อย่าง​ที่​เคย​เป็น​เรื่อง​ภายใน​โพรเซส กลาย​เป็น​เรื่อง​ข้าม​เครือข่าย​ที่​ต้อง​เฝ้า

สังเกต​ว่า​ของ​ที่​ซื้อ​มา​กับ​ต้นทุน​ที่​จ่าย​ไป​เป็น สอง​ด้าน​ของ​เหรียญ​เดียวกัน — deploy อิสระ​ได้ ก็​เพราะ​มัน​แยก​เครื่อง และ​เพราะ​มัน​แยก​เครื่อง​นั่น​คือ​ถึง​มี network กับ partial failure ตาม​มา นี่​คือ trade-off ที่ Kaen เขียน​ถึง​ละเอียด​ใน​บทความ ชั่ง​น้ำ​หนักข้อ​แลกเปลี่ยน​ของ Microservices (แปล​จาก​งาน​ของ Martin Fowler) — อ่าน​คู่​กับ​บท​นี้​ได้​เต็มๆ

กฎ​ที่​ใช้ได้​จริง​มี​ประโยค​เดียว: เริ่ม​ที่ modular monolith เสมอ แล้ว​แตก​เฉพาะ​เมื่อ​มี​แรงผลัก​จริง (real force) มา​บังคับ​เท่านั้น “code เริ่ม​ยาว” ไม่ใช่ force “อยาก​ลอง​ใช้ RabbitMQ” ไม่ใช่ force “microservices ดู​ทันสมัย​กว่า” ยิ่ง​ไม่ใช่ force แรงผลัก​ที่​นับ​ว่า​เป็น force จริง​มี​ลักษณะ​แบบ​นี้:

  • ต้อง scale อิสระ — context หนึ่ง​กิน​ทรัพยากร​คนละ​แบบ​กับ​ที่​เหลือ​อย่าง​ชัดเจน เช่น Kitchen รับ​โหลด​พุ่ง​ช่วง​มื้อ​เที่ยง​จน​ต้อง​เพิ่ม​เครื่อง​เฉพาะ​มัน การ​ขยาย​ทั้ง monolith เพื่อ context เดียว​เริ่ม​ไม่​คุ้ม
  • team boundary — มี​ทีม​แยก​ที่​ควร​เป็น​เจ้าของ context นั้น​เต็ม​ตัว ตัดสิน​ใจ​และ​ปล่อย​ของ​เอง​ได้​โดย​ไม่​ต้อง​รอ​ใคร (นี่​คือ​กฎ​ของ Conway — โครงสร้าง​ระบบ​มัก​สะท้อน​โครงสร้าง​ทีม การ​มี​ทีม​แยก​จริง​คือ​หนึ่ง​ใน​เหตุผล​ที่​หนักแน่น​ที่สุด)
  • release cadence ต่าง​กัน — context หนึ่ง​ต้อง​ปล่อย​ของ​ถี่​มาก อีก​อัน​เปลี่ยน​แทบ​ไม่​บ่อย การ​มัด​ให้ deploy พร้อม​กัน​เริ่ม​ฉุด​ฝั่ง​ที่​อยาก​ไป​เร็ว

ถ้า​ไม่มี​ข้อ​ไหน​จริง​เลย คำ​ตอบ​คือ​อยู่​เป็น module ใน monolith ต่อ​ไป — นั่น​ไม่ใช่​การ​ผัด​วัน​ประกัน​พรุ่ง แต่​คือ​การ​เลือก​ที่​ถูก แผนภาพ​ข้าง​ล่าง​สรุป​การ​ตัดสิน​ใจ​นี้​เป็น​ทาง​เดิน:

flowchart TD
  START["context หนึ่งใน modular monolith"] --> Q1{"มี force จริง<br/>บังคับให้แตกไหม?"}
  Q1 -->|"ไม่มี"| STAY["อยู่เป็น module ต่อไป<br/>in-process · deploy ก้อนเดียว · transaction เดียว"]
  Q1 -->|"มี: scale อิสระ /<br/>team boundary /<br/>release cadence ต่างกัน"| Q2{"ขอบเขต context<br/>คมพอหรือยัง?"}
  Q2 -->|"ยังไม่คม<br/>(ยังอ้าง type ข้าม module)"| FIX["ทำขอบเขตให้คมก่อน<br/>แยก schema · คุยผ่าน integration event ล้วน"]
  FIX --> Q2
  Q2 -->|"คมแล้ว"| Q3{"พร้อมจ่ายต้นทุน<br/>ระบบกระจายไหม?"}
  Q3 -->|"ยังไม่พร้อม"| STAY
  Q3 -->|"พร้อม: latency · partial failure ·<br/>eventual consistency · ops + broker"| EXTRACT["แตกเป็น service<br/>+ ตั้ง message broker"]
  classDef stay fill:#bbf7d0,stroke:#166534,color:#14532d;
  classDef work fill:#fde68a,stroke:#92400e,color:#451a03;
  classDef go fill:#c7d2fe,stroke:#3730a3,color:#1e1b4b;
  class STAY stay;
  class FIX work;
  class EXTRACT go;

คำ​บรรยาย​ภาพ: ทาง​เดิน​ตัดสิน​ใจ “ควร​แตก context นี้​ไหม?” ด่าน​แรกถามหา force จริง — ถ้า​ไม่มี ก็​จบ​ที่​กล่อง​เขียว (อยู่​เป็น module ต่อ​ไป) ซึ่ง​เป็น​คำ​ตอบ​ของ context ส่วน​ใหญ่ ถ้า​มี force ด่าน​สอง​เช็กว่า​ขอบเขต​คม​พอ​หรือ​ยัง ถ้า​ยัง​ต้อง​วน​กลับ​ไป​ทำให้​คม​ก่อน (กล่อง​เหลือง) อย่า​แตก​ก้อน​ที่​ยัง​พัน​กัน และ​แม้​ขอบเขต​คม​แล้ว​ก็​ยัง​ต้อง​ผ่าน​ด่าน​สาม — พร้อม​จ่าย​ต้นทุน​ของ​ระบบ​กระจาย​ไหม ถ้า​ยัง​ไม่​พร้อม คำ​ตอบ​ก็​ยัง​คือ​กลับ​มา​อยู่​ใน monolith ก่อน มี​แค่​ทาง​เดียว​เท่านั้น​ที่​พา​ไป​ถึง​กล่อง​น้ำเงิน (แตก​เป็น service)

ตอน​อยู่​ใน modular monolith เวลา Ordering ยืนยัน​ออเดอร์ มัน​ปล่อย OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) แล้ว handler ของ Kitchen รับ​ไป​เปิด​ตั๋ว — ทั้งหมด​เกิด​ใน​โพรเซส​เดียว เป็นการ​เรียก method ตรงๆ พอ​แตก Kitchen ออก​ไป​เป็น service คนละ​เครื่อง การ​ส่ง event นั้น​ก็​ข้าม​เครื่อง​ไม่​ได้​ด้วย​การ​เรียก method อีก​ต่อ​ไป มัน​ต้อง​มี​ตัวกลาง​คอย​รับ-ส่ง​ข้อความ​ให้ นั่น​คือ Message BrokerMessage Brokerตัวกลาง​ที่​รับ-ส่ง​ข้อความ​ระหว่าง context ที่​แยก​เป็น service จริง​แล้ว เช่น RabbitMQ ทำให้​ผู้​ส่ง​ไม่​ต้อง​รู้จัก​ผู้รับ​โดยตรง (decoupled) — เป็น​สิ่ง​ที่​ต้อง​มี​เมื่อ​แตก​จาก modular monolith ไป​เป็น distributed serviceArchitecture (เช่น RabbitMQ) — ผู้​ส่ง​โยน event ให้ broker แล้ว​จบ​หน้าที่ ไม่​ต้อง​รู้​ด้วย​ซ้ำ​ว่า​ใคร​เป็น​ผู้รับ broker จัดการ​ส่ง​ต่อ​ให้​เอง สอง​ฝั่ง​จึง​หลุด​จาก​กัน (decoupled)

แต่ broker มา​พร้อม​กฎ​ของ​มัน​เอง: มัน​การันตี​แบบ At-least-once deliveryAt-least-once Deliveryการ​รับประกัน​ของ message broker ว่า​ข้อความ​จะ​ถูก​ส่ง​ถึง​ผู้รับ 'อย่าง​น้อยหนึ่ง​ครั้ง' แต่​อาจ​ซ้ำ​ได้​ใน​กรณี​ล่ม/retry ผู้รับ​จึง​ต้อง​เป็น idempotent consumer เพื่อ​รองรับ​ความ​ซ้ำ​นี้Architecture คือ “ส่ง​ถึง​อย่าง​น้อยหนึ่ง​ครั้ง” ไม่ใช่ “พอดี​หนึ่ง​ครั้ง” เวลา​ล่ม​แล้ว retry ข้อความ​เดิม​อาจ​มา​ซ้ำ ฝั่ง​รับ​จึง​ต้อง​เป็น Idempotent ConsumerIdempotent Consumerผู้รับ event ที่​ออกแบบ​ให้​ประมวล​ผล​ข้อความ​เดิม​ซ้ำ​ได้​โดย​ผลลัพธ์​เหมือน​เดิม (ไม่ใช่​ผล​ซ้ำซ้อน) จำเป็น​เพราะ at-least-once delivery การันตี​แค่ 'ส่ง​ถึง​อย่าง​น้อยหนึ่ง​ครั้ง' ไม่ใช่ 'พอดี​หนึ่ง​ครั้ง'Architecture — ประมวล​ผล​ข้อความ​เดิม​ซ้ำ​ได้​โดย​ผลลัพธ์​ไม่​เพี้ยน (เปิด​ตั๋ว​ให้​ออเดอร์​เดิม​สอง​ครั้ง ต้อง​ได้​ตั๋ว​ใบ​เดียว ไม่ใช่​สอง​ใบ) และ​เพราะ​ข้อความ​ต้อง serialize ข้าม network ตัว event ก็​ต้อง​เป็น contract ที่​นิ่ง มี​แต่ id กับ​ค่า​พื้นฐาน — คือ​สิ่ง​ที่ integration event เป็น​มา​ตั้งแต่​ต้น​อยู่​แล้ว ไม่ใช่ domain object

และ​นี่​คือ​จุด​สำคัญ: code ที่​ปล่อย event ไม่​ต้อง​เปลี่ยน​เลย ตัว Ordering ยัง publish integration event ตัว​เดิม สิ่ง​ที่​เปลี่ยน​คือ implementation ของ port ตัว​เดียว — จาก bus ที่ dispatch ใน​โพรเซส เป็น bus ที่​ยิง​ผ่าน broker:

ขอบเขต​ที่​คม​ทำให้ 'แตก' = สลับ implementation ของ port เดียว
public interface IIntegrationEventBus
{
Task PublishAsync(IIntegrationEvent e, CancellationToken ct);
}
// modular monolith: ส่งในโพรเซส เรียกตรงเข้า handler ของอีก module
public sealed class InProcessIntegrationEventBus : IIntegrationEventBus
{
// dispatch ในโพรเซส — ไม่มี network, อยู่ใน transaction เดียวกันได้
public Task PublishAsync(IIntegrationEvent e, CancellationToken ct) => /* เรียก handler ตรง ๆ */;
}
// หลังแตกเป็น service: ยิงผ่าน message broker (เช่น RabbitMQ)
public sealed class BrokerIntegrationEventBus : IIntegrationEventBus
{
// serialize contract แล้วส่งข้าม network — at-least-once, ฝั่งรับต้องเป็น idempotent consumer
public Task PublishAsync(IIntegrationEvent e, CancellationToken ct) => /* publish เข้า broker */;
}

Ordering ที่​เรียก PublishAsync ไม่รู้​และ​ไม่​ต้อง​รู้​ว่า​ปลายทาง​อยู่​ใน​โพรเซส​เดียวกัน​หรือ​คนละ​เครื่อง มัน​เห็น​แค่ IIntegrationEventBus การ​แตก​เป็น service จึง​กลาย​เป็น​เรื่อง​ของ​การ​เปลี่ยน transport ไม่ใช่​การ​รื้อ model

ขอบเขต​ที่​คม​คือ “ออปชัน” ที่​ซื้อ​ไว้​ล่วงหน้า

หัวข้อ​ที่​มีชื่อ​ว่า “ขอบเขต​ที่​คม​คือ “ออปชัน” ที่​ซื้อ​ไว้​ล่วงหน้า”

ย้อน​ดู​สิ่ง​ที่​เรา​ลงแรง​ทำ​มา​ทั้ง​คอร์ส — แยก4 context ให้​แต่ละ​อัน​เป็น​เจ้าของ schema ตัวเอง บังคับ​ให้​คุย​กัน​ผ่าน integration event เท่านั้น ใช้ Architecture TestsArchitecture Teststest อัตโนมัติ​ที่​ตรวจ​กฎ​โครงสร้าง code เช่น Kitchen ต้อง​ไม่ reference ชนิด​ของ Ordering โดยตรง ด้วย​เครื่องมือ​อย่าง NetArchTest/ArchUnitNET ทำให้​ขอบเขต​ของ modular monolith ไม่​หลุด​โดย​ไม่มี​ใคร​รู้Architecture กัน​ไม่​ให้ module หนึ่ง​แอบ​อ้าง type ของ​อีก module — ทั้งหมด​นั้น​ไม่ใช่​พิธีกรรม​เปล่าๆ มัน​คือ​การ ซื้อออปชัน​การ​แตก​ไว้​ล่วงหน้า​ใน​ราคา​ถูก เพราะ​ขอบเขต​คม​อยู่​แล้ว วัน​ที่​มี force จริง​มา​ถึง การ​แตก​ก็​เป็น​แค่​การ​สลับ implementation ของ bus หนึ่ง​ตัว​กับ​ตั้ง broker ขึ้น​มา ไม่ใช่​การ​มา​นั่ง​เลาะ model ที่​พัน​กัน​ออก​จาก​กัน​ทีหลัง — ซึ่ง​เป็น​งาน​ที่​แพง​และ​เสี่ยง​ที่สุด

กลับ​กัน ถ้า​เรา​ข้าม​ขั้นตอน​ทำ​ขอบเขต​ให้​คม แล้ว​รีบ​แตก​เป็น service ตั้งแต่​แรก​เพราะ​เห็น​ว่า “ทันสมัย​กว่า” เรา​จะ​ได้​สิ่ง​ที่​แย่​ที่สุด​ของ​ทั้ง​สอง​โลก — model ยัง​พัน​กัน​เหมือน big ball of mud แต่​คราว​นี้​พัน​กัน​ข้าม network ที่ debug ยาก​กว่า​เดิม​หลาย​เท่า จ่าย​ต้นทุน​ระบบ​กระจาย​เต็ม​ราคา​โดย​ไม่​ได้​ประโยชน์​ของ​การ​แยกจริงๆ เลย

เพราะ​ฉะนั้น​ข้อความ​หลัก​ของ​บท​นี้​จึง​ตรง​ไป​ตรง​มา: ระบบ​ส่วน​ใหญ่​ไม่​ควร​แตก​เป็น service — อย่าง​น้อย​ก็​ยัง​ไม่ใช่​ตอน​นี้ modular monolith ที่​ขอบเขต​คม​คือ​ปลายทาง​ที่​ดี​พอ​สำหรับ​งาน​จำนวน​มาก และ​มัน​เก็บออปชัน​การ​แตก​ไว้​ให้​เรา​หยิบ​ใช้ได้​ทันที​ที่ (และ​ถ้า) วัน​นั้น​มา​ถึง​จริง การ​แตก​ที่​ดี​คือ​การ​แตก​ที่​ถูก​บังคับ​ด้วย​ความ​จำเป็น ไม่ใช่​ถูก​ล่อ​ด้วย​ความ​อยาก​ลอง​ของ​ใหม่ — และ​ใน​บท​สุดท้าย เรา​จะ​ลงมือ​แตก1 context ออกไปจริงๆ ตอน​ที่ force มา​ถึง​แล้ว


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

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

  • Conway’s Law — โครงสร้าง​ระบบ​มัก​สะท้อน​โครงสร้าง​ทีม ทำไม “team boundary” ถึง​เป็น​เหตุผล​แตก service ที่​หนักแน่น​ที่สุด
  • YAGNI — อย่า​สร้าง​สิ่ง​ที่​ยัง​ไม่​จำเป็น หลัก​คิด​เบื้องหลัง “อย่า​แตก​เร็ว​เกิน​ไป”
  • Strangler Fig Pattern — วิธี​ค่อยๆ เลาะ context ออก​จาก monolith อย่าง​ปลอดภัย​เมื่อ​ถึง​เวลา​ต้อง​แตก​จริง

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

ข้อ 1 / 3

ข้อใดคือเหตุผลที่ 'ใช้ได้จริง' (real force) ในการแตก context ออกเป็น service?