เมื่อไรควร (และไม่ควร) แตกเป็น 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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) บทนี้เป็นภาคตัดสินใจ (decision) มากกว่าภาคลงมือ — เราจะชั่งน้ำหนักว่าเมื่อไรควรแตก โดยมีตัวอย่าง code สั้นๆ ให้เห็นว่า “รอยต่อ” ที่ทำให้แตกได้ถูกที่ตรงไหน ส่วนการแตก context จริงลงมือกันในบทสุดท้าย
สิ่งที่ modular monolith ให้มาฟรีๆ
หัวข้อที่มีชื่อว่า “สิ่งที่ modular monolith ให้มาฟรีๆ”ก่อนจะพูดถึงการแตก ต้องเห็นก่อนว่าการ ไม่แตก ให้อะไรเรามาบ้าง เพราะทุกอย่างใน 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 ครบโดยไม่ต้องจ่ายต้นทุนของระบบกระจาย
สิ่งที่ต้องจ่ายเมื่อแตกเป็น service
หัวข้อที่มีชื่อว่า “สิ่งที่ต้องจ่ายเมื่อแตกเป็น service”การแตก 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 ก่อน แตกเมื่อมี force จริง
หัวข้อที่มีชื่อว่า “กฎ: modular monolith ก่อน แตกเมื่อมี force จริง”กฎที่ใช้ได้จริงมีประโยคเดียว: เริ่มที่ 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)
message broker: สิ่งที่การแตกเรียกร้อง
หัวข้อที่มีชื่อว่า “message broker: สิ่งที่การแตกเรียกร้อง”ตอนอยู่ใน 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:
public interface IIntegrationEventBus{ Task PublishAsync(IIntegrationEvent e, CancellationToken ct);}
// modular monolith: ส่งในโพรเซส เรียกตรงเข้า handler ของอีก modulepublic 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:
- Conway’s Law — โครงสร้างระบบมักสะท้อนโครงสร้างทีม ทำไม “team boundary” ถึงเป็นเหตุผลแตก service ที่หนักแน่นที่สุด
- YAGNI — อย่าสร้างสิ่งที่ยังไม่จำเป็น หลักคิดเบื้องหลัง “อย่าแตกเร็วเกินไป”
- Strangler Fig Pattern — วิธีค่อยๆ เลาะ context ออกจาก monolith อย่างปลอดภัยเมื่อถึงเวลาต้องแตกจริง
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ข้อใดคือเหตุผลที่ 'ใช้ได้จริง' (real force) ในการแตก context ออกเป็น service?