Modular Monolith, Vertical Slice, Clean Architecture: สามคำที่ไม่ได้แข่งกัน
คำถามที่เจอบ่อยในรีวิวสถาปัตยกรรมคือ “project นี้ควรเป็น modular monolith, vertical slice หรือ clean architecture ดี” — คำถามนี้ตอบไม่ได้ ไม่ใช่เพราะมันยาก แต่เพราะมันวางตัวเลือกสามอันที่ไม่ได้แข่งกันไว้ในบรรทัดเดียวกัน
- Modular Monolith — ตอบว่าอะไรอยู่ใน module เดียวกันและ module คุยกันผ่านอะไร
- Vertical Slice — ตอบว่า file ของ feature หนึ่งอยู่ด้วยกันหรือกระจายตามชนิดทางเทคนิค
- Clean Architecture — ตอบว่าลูกศร dependency ชี้ไปทางไหน
สามคำถามนี้ตอบพร้อมกันได้ และตอบแยกกันก็ได้ — ระบบที่เป็น modular monolith แล้วภายในแต่ละ module จัด code เป็น slice ตาม feature โดยไม่มีชั้น Domain แยกเลยสัก module เป็นของที่มีอยู่จริงและทำงานได้ดี
สามคำนี้เป็นสามสวิตช์ ไม่ใช่สามตัวเลือก จึงมีชุดที่เป็นไปได้ 2³ = 8 ชุด และทั้งแปดชุดมีคนใช้จริง คำถามที่ตอบได้จึงไม่ใช่ “เลือกอันไหน” แต่คือ “module นี้ควรเปิดกี่แกน” — คำตอบต่างกันได้ภายในระบบเดียวกัน
ทุก block code ในหน้านี้อยู่บน domain FoodOrdering เดียวกับที่คอร์สทั้งสายใช้ — ระบบสั่งอาหารที่มี Order เป็นตัวเอก และมี Delivery เป็นความสามารถอีกก้อนที่อยู่ในระบบเดียวกัน
หน้านี้เป็นบทความ เพื่อการตัดสินใจ ไม่ใช่เพื่อสอนเทคนิค — วิธีลงมือทำจริงของแต่ละแกนอยู่ในคอร์สแล้ว
- Clean Architecture ด้วย .NET สำหรับทิศของ dependency
- DDD ใน code จริง สำหรับการเติมกฎเข้าไปใน domain
- แยก Bounded Context แล้วเชื่อมกลับ สำหรับ module และการแตกออกเป็น service
ด้านบนมีสวิตช์ ระดับผู้อ่าน อยู่หนึ่งตัว L1 เห็นเส้นเรื่องหลักและ block code ครบทุกก้อน (code ไม่เคยถูกซ่อนที่ระดับไหนเลย เพราะย่อหน้าที่อธิบายมันจะกลายเป็นย่อหน้าลอย ๆ ทันที)
L2 ซึ่งเป็นค่าเริ่มต้นเปิดเพิ่มอีกสามย่อหน้า — เรื่องตารางของใครของมันใน module, เหตุผลที่ port ไปอยู่ข้าง slice และการที่ฝั่งอ่านข้าม domain ได้
ส่วน L3 เปิดเพิ่มอีกสี่ย่อหน้าที่ลงไปถึงระดับที่ต้องเคยเจ็บมาก่อนถึงจะสนใจ — transaction ที่คร่อม module, event ที่ทำให้ผู้ใช้เห็น 500, ขอบเขตที่ architecture test มองไม่เห็น และราคาจริงของการแตก module ออกเป็น service
ส่วน เครื่องประกอบสามแกน ที่อยู่กลางหน้าแสดงเหมือนกันทุกระดับ ระดับผู้อ่านไม่ได้คุมอะไรในกล่องนั้นเลย
สามคำถามที่ต่างกัน
หัวข้อที่มีชื่อว่า “สามคำถามที่ต่างกัน”แต่ละแกนตอบได้แค่คำถามของตัวเอง และนิยามของทั้งสามคำก็มาจากคนละที่และคนละปี
วิธีที่เร็วที่สุดในการเห็นว่าสามคำนี้ไม่ทับกัน คือถามคำถามของแต่ละแกนกับ code ก้อนเดียวกัน แล้วสังเกตว่าคำตอบของแกนหนึ่งไม่ได้บังคับคำตอบของอีกสองแกนเลย
| แกน | คำถามที่มันตอบ | สิ่งที่มัน ไม่ ได้ตอบ |
|---|---|---|
| Modular Monolith | อะไรอยู่ใน module เดียวกัน และ module คุยกันผ่านอะไร | ภายใน module จัด file อย่างไร · dependency ชี้ทางไหน |
| Vertical Slice | file ของ feature หนึ่งอยู่ด้วยกันไหม | ระบบมีกี่หน่วย deploy · ใครถูกห้ามรู้จักใคร |
| Clean Architecture | ใครถูกห้ามรู้จักใคร | code จัดเป็น folder แบบไหน · ระบบมีกี่ module |
flowchart TB Code[The same PlaceOrder code] Code --> Q1[Which module owns it] Code --> Q2[Where do its files live] Code --> Q3[Which way do dependencies point] Q1 --> A1[Modular Monolith answers this] Q2 --> A2[Vertical Slice answers this] Q3 --> A3[Clean Architecture answers this]
ภาพ 1 — code ก้อนเดียวถูกถามสามคำถามที่เป็นอิสระจากกัน แต่ละแกนตอบได้แค่คำถามของตัวเอง และไม่มีแกนไหนบังคับคำตอบของอีกสองแกน
นิยามของแต่ละคำมาจากคนละที่และคนละปี ซึ่งเป็นเหตุผลหนึ่งที่มันไม่เคยถูกออกแบบมาให้เทียบกัน
- Kamil Grzybek นิยาม monolith ไว้สั้นที่สุดเท่าที่จะสั้นได้ว่าคือระบบที่มี หน่วย deploy เพียงหน่วยเดียว แล้วเติมว่าคำว่า modular มาจาก modular programming คือการแยกงานออกเป็น module ที่เป็นอิสระต่อกันและสลับเปลี่ยนได้
- Jimmy Bogard อธิบายสถาปัตยกรรมของเขาว่า “built around distinct requests, encapsulating and grouping all concerns from front-end to back” — สร้างรอบ request ไม่ใช่รอบชั้นเทคนิค
- Robert C. Martin เสนอ Clean Architecture ในปี 2012 เป็นการสังเคราะห์ Hexagonal กับ Onion ให้เหลือกฎข้อเดียวคือ Dependency Rule (มีฉบับเต็มอยู่ที่บทความ The Clean Architecture บนไซต์นี้)
เครื่องประกอบสามแกน
หัวข้อที่มีชื่อว่า “เครื่องประกอบสามแกน”ทุกชุดมีทั้งของที่ได้และราคาที่จ่าย แม้แต่ชุดที่ปิดทั้งสามแกนก็ยังชนะเรื่องหนึ่งที่ไม่มีชุดไหนชนะได้อีก
เครื่องประกอบสามแกน
เปิด 2 จาก 3 แกนmodule ที่ภายในเป็น slice
src/Modules/Ordering/ ├─ PlaceOrder/ Endpoint.cs Handler.cs │ Order.cs ├─ CancelOrder/ Endpoint.cs Handler.cs └─ OrderingModule.cs src/Modules/Delivery/ (โครงเดียวกัน)
ได้
- เส้นแบ่งชัดทั้งสองระดับ: ระหว่าง module และระหว่าง feature ภายใน module
- งานหนึ่งชิ้นมักจบใน folder เดียว ทำให้ merge conflict ระหว่างทีมน้อยลงจริง
- module ที่เป็น CRUD ล้วนไม่ต้องแบกชั้น Domain ที่ไม่มีอะไรอยู่ข้างใน
จ่าย
- กฎธุรกิจกระจายอยู่ใน handler — เทสกฎหนึ่งข้อยังต้องมีฐานข้อมูล
- กฎเดียวกันที่ถูกใช้จาก2 slice ต้องอาศัยวินัยของคนไม่ให้เขียนคนละแบบ
- ตอนแตก module ออกเป็น service กฎที่กระจายอยู่ต้องถูกตามเก็บทีละ handler
เหมาะกับ module ที่มี feature เยอะแต่กฎตื้น เช่นงานฝั่งรายงานหรือ back-office
เจอได้ที่ รูปแบบที่พบบ่อยที่สุดใน code modular monolith สมัยใหม่ของสาย .NET
module ที่ภายในเป็น slice — เหมาะกับ module ที่มี feature เยอะแต่กฎตื้น เช่นงานฝั่งรายงานหรือ back-officeลองปิดทั้งสามแกนดูก่อนแล้วค่อยเปิดทีละอัน จะเห็นว่า column “จ่าย” ไม่เคยว่างเลยสักชุด
ชุดที่ควรลองก่อนคือชุดที่ปิดทั้งสามแกน เพราะมันคือจุดตั้งต้นของระบบเกือบทุกระบบ และเป็นชุดที่ ชนะจริงในเรื่องหนึ่งที่ไม่มีชุดไหนชนะได้อีก: เวลาที่ใช้พาคนใหม่ให้แก้ bug แรกได้
ระบบที่มี3 folder และ OrderService ก้อนเดียว คนที่เพิ่งเข้าทีมวันแรกอ่านเส้นทางของ POST /orders จบได้ใน file เดียว
ส่วนชุดที่เปิดครบสามแกน คนคนเดียวกันต้องเข้าใจว่า module คืออะไร slice คืออะไร และทำไม IOrderRepository ถึงอยู่คนละที่กับ EfOrderRepository ก่อนจะแก้บรรทัดแรกได้
ทุกชุดในกล่องข้างบนมีของที่ได้ ถ้าอ่านแต่ column ซ้ายจะรู้สึกว่าเปิดครบสามแกนดีที่สุดเสมอ ซึ่งไม่จริง — ราคาของแกนหนึ่งจ่ายเป็นเวลาของคนทั้งทีมทุกวัน ส่วนผลตอบแทนของมันมาเป็นก้อนตอนที่ระบบโตถึงจุดหนึ่งเท่านั้น และถ้าไม่ถึงจุดนั้น ก็ไม่มีผลตอบแทน
code ของสามชุดที่คนใช้บ่อยที่สุด
หัวข้อที่มีชื่อว่า “code ของสามชุดที่คนใช้บ่อยที่สุด”ทั้งสามก้อนทำงานเดียวกันทุกประการ สิ่งที่ต่างกันคือที่อยู่ของกฎ ไม่ใช่ปริมาณ code
เพื่อให้เห็นว่าสิ่งที่ต่างกันคือ ที่อยู่ของกฎ ไม่ใช่ปริมาณ code ทั้งสามก้อนข้างล่างทำงานเดียวกันทุกประการ: รับคำสั่งซื้อ ตรวจว่าตะกร้าไม่ว่าง บันทึก แล้วคืนรหัสออเดอร์
ชุด Vertical Slice ล้วน
หัวข้อที่มีชื่อว่า “ชุด Vertical Slice ล้วน”ทั้ง feature อยู่ใน folder เดียว และ handler คุยกับ DbContext ตรง ๆ ไม่มีชั้นคั่น:
// src/Features/PlaceOrder/Handler.cs — ทั้ง slice อยู่ใน folder เดียวกับ Endpoint.cs// Order ฝั่งนี้เป็น anemic model (property get/set ล้วน) id จึงเป็น Guid เปล่า ไม่ใช่ value objectpublic async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct){ if (cmd.Lines.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order { Id = Guid.NewGuid(), RestaurantId = cmd.RestaurantId, Total = cmd.Lines.Sum(l => l.UnitPrice * l.Qty), Status = "Placed", };
_db.Orders.Add(order); await _db.SaveChangesAsync(ct); return order.Id;}กฎ “ตะกร้าว่างสร้างออเดอร์ไม่ได้” อยู่ในบรรทัดแรกของ handler ซึ่งอ่านง่ายมาก — และนั่นคือทั้งข้อดีและข้อเสียของมัน
ข้อดีคือเปิด file เดียวเห็นครบ ข้อเสียคือถ้าวันหนึ่งมี slice ที่สองสร้างออเดอร์ได้ด้วย (เช่น สั่งซ้ำจากออเดอร์เก่า) กฎข้อนี้ต้องถูกเขียนใหม่อีกครั้ง และไม่มีอะไรบอกได้ว่าสองที่นั้นเขียนตรงกัน
ชุด module + slice
หัวข้อที่มีชื่อว่า “ชุด module + slice”code เดิมทุกบรรทัด ย้ายที่อยู่ และเพิ่มหน้าสาธารณะของ module เข้ามา:
// src/Modules/Ordering/PlaceOrder/Handler.cs — เนื้อในเหมือนเดิมทุกบรรทัด// สิ่งที่เพิ่มมาคือกำแพงรอบ module ไม่ใช่กำแพงรอบกฎ
// src/Modules/Ordering/OrderingModule.cs — ทางเข้าเดียวที่ module อื่นเรียกได้public interface IOrderingModule{ Task<OrderSummary?> GetSummaryAsync(OrderId id, CancellationToken ct);}
// src/Modules/Delivery/AssignRider/Handler.cs — ฝั่ง Delivery เรียกได้แค่ผ่าน interfacevar summary = await _ordering.GetSummaryAsync(cmd.OrderId, ct) ?? throw new InvalidOperationException("ไม่พบออเดอร์ที่อ้างถึง");สังเกตว่าเนื้อในของ handler ไม่เปลี่ยนเลยสักบรรทัด สิ่งที่เพิ่มเข้ามาคือ กำแพงรอบ module — Delivery เรียก Ordering ได้ทางเดียวคือผ่าน IOrderingModule และเรียกตารางของ Ordering ตรง ๆ ไม่ได้
นี่คือทั้งหมดที่แกน Modular Monolith เพิ่มให้ ไม่มากไปกว่านี้ และโดยเฉพาะอย่างยิ่ง มันไม่ได้ย้ายกฎธุรกิจไปไหนเลย กฎยังอยู่ใน handler เหมือนเดิม
กำแพงที่ว่านี้จะจริงก็ต่อเมื่อมันคลุมถึงฐานข้อมูลด้วย ในทางปฏิบัติแปลว่าแต่ละ module มี schema ของตัวเอง (ordering.Orders, delivery.Assignments) และ DbContext ของ module 1 map เฉพาะตารางของตัวเอง
ถ้าทั้งระบบยังใช้ DbContext ก้อนเดียวที่เห็นทุกตาราง ใครก็ตามที่เขียน LINQ ข้าม module จะทำได้โดย compile ผ่านสบาย ๆ และกำแพงจะเหลือแค่ชื่อ folder ภายในสองสปรินต์
ชุดที่เปิดครบสามแกน
หัวข้อที่มีชื่อว่า “ชุดที่เปิดครบสามแกน”code ก้อนเดิมย้ายกฎออกจาก handler เข้าไปอยู่ใน domain:
// src/Modules/Ordering/Domain/Order.cs — factory ทางเดียวที่สร้าง Order ได้public static Order Place(OrderId id, IReadOnlyList<OrderLine> items){ if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items); order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); return order;}// src/Modules/Ordering/Features/PlaceOrder/Handler.cs — เหลือแค่ประสานงาน ไม่มีกฎของตัวเองpublic async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct){ var items = new List<OrderLine>(); foreach (var item in cmd.Items) { var unitPrice = await _menu.GetPriceAsync(item.ProductId, ct); items.Add(new OrderLine(new OrderLineId(Guid.NewGuid()), item.ProductId, new Quantity(item.Quantity), unitPrice)); }
var order = Order.Place(new OrderId(Guid.NewGuid()), items);
await _orders.SaveAsync(order, ct); return order.Id;}ความต่างที่จับต้องได้ที่สุดของก้อนนี้ไม่ได้อยู่ที่จำนวน file แต่อยู่ที่ประโยคนี้: ไม่มี code ตรงไหนในระบบสร้าง Order ที่ตะกร้าว่างขึ้นมาใหม่ได้
เพราะ new Order(...) เป็น private และ Place เป็นทางเดียวที่เปิดไว้ slice ที่สองที่มาทีหลังจึงไม่ต้องจำกฎ มันถูกบังคับให้เดินผ่านกฎอยู่แล้ว (ส่วนออเดอร์ที่ EF Core โหลดกลับขึ้นมาจากแถวเดิมเป็นคนละเรื่อง เพราะตอนนั้นไม่มีใครเรียก factory ตัวนี้ — ข้อจำกัดข้อนี้จริงกับทุกชุดที่มี domain)
สังเกตว่า IOrderRepository กับ IMenuCatalog ที่ handler ก้อนนี้ใช้ ไม่ได้อยู่ใน folder Interfaces/ รวมของทั้งระบบ แต่อยู่ข้าง ๆ slice ที่ใช้มัน
นี่คือจุดที่แกน Vertical Slice ยังทำงานอยู่แม้จะเปิด Clean ด้วย — Dependency Rule บังคับแค่ว่า วงในห้ามรู้จักวงนอก มันไม่ได้บังคับว่า interface ต้องกองรวมกันอยู่ที่ไหน
การวาง port ไว้ข้างผู้ใช้ของมันทำให้ลบ slice ทิ้งแล้ว port ที่ไม่มีใครใช้หายไปพร้อมกัน แทนที่จะค้างอยู่ใน folder รวมไปอีกสองปี
และไม่ใช่ทุก slice ที่ต้องเดินผ่าน domain slice ฝั่งอ่านอย่าง “ดูประวัติออเดอร์ย้อนหลัง” ไม่มีกฎอะไรให้บังคับเลยสักข้อ การบังคับให้มันโหลด aggregate ขึ้นมาทั้งก้อนเพื่อ map เป็น DTO คือการจ่ายค่าผ่านทางโดยไม่ได้อะไรกลับมา
slice แบบนั้นยิง query ตรงเข้า read model ได้ และนั่นคือ CQRS ฉบับที่เบาที่สุดเท่าที่จะเบาได้ — ไม่ต้องมี bus ไม่ต้องมีสองฐานข้อมูล แค่ยอมรับว่าฝั่งอ่านกับฝั่งเขียนไม่จำเป็นต้องเดินทางเดียวกัน
จุดที่สามแกนชนกันจริง
หัวข้อที่มีชื่อว่า “จุดที่สามแกนชนกันจริง”Vertical Slice ซื้ออิสระราย slice Clean ซื้อความสม่ำเสมอ และทางออกคือความเข้มของ Dependency Rule เป็นเรื่องราย module
ถึงตรงนี้บทความอาจฟังดูเหมือนว่าสามแกนอยู่ด้วยกันได้อย่างสงบเสมอ ซึ่งไม่จริง มีอยู่จุดหนึ่งที่ Vertical Slice กับ Clean Architecture ขัดกันตรง ๆ และเป็นข้อโต้แย้งที่ Bogard เขียนไว้เองในบทความต้นทาง
เขาบอกว่าสถาปัตยกรรมแบบ layer ทำให้เกิด abstraction จำนวนมากรอบสิ่งที่ไม่ควรถูก abstract ตั้งแต่แรก และหลักการที่เขายึดคือ “Minimize coupling between slices, and maximize coupling in a slice” — ให้แต่ละ slice ตัดสินใจเองได้ ว่าจะทำงานให้เสร็จด้วยวิธีไหน
นั่นชนกับ Dependency Rule โดยตรง เพราะ Dependency Rule ไม่อนุญาตให้ slice ตัดสินใจเองว่าจะคุย DbContext ตรงหรือไม่ — มันห้ามไว้เท่ากันทุก slice
พูดให้ตรงคือ แกน Clean ซื้อ ความสม่ำเสมอ มาด้วยราคาของ อิสระราย slice และแกน Vertical Slice ซื้ออิสระราย slice มาด้วยราคาของความสม่ำเสมอ ทั้งสองอย่างเป็นของจริงและแลกกันจริง
เมื่อ interface ตัวหนึ่งมีผู้ implement คนเดียวตลอดอายุ project และไม่เคยถูก mock ในเทสสักครั้ง (เพราะเทสของ slice นั้นใช้ฐานข้อมูลจริงใน container อยู่แล้ว) interface นั้นไม่ได้ให้อะไรเลยนอกจากการกด F12 แล้วเจอ file ที่ไม่มี code นี่คือข้อโต้แย้งของ Bogard ในรูปแบบที่เจอได้ใน code จริง และมันถูกต้อง
ทางออกที่ทีมส่วนใหญ่ลงเอยไม่ใช่การเลือกข้าง แต่คือการยอมรับว่า ความเข้มของ Dependency Rule เป็นเรื่องราย module ไม่ใช่รายระบบ
module Ordering ที่มีกฎเรื่องยอดขั้นต่ำ สถานะ และการยกเลิก เปิด Clean เต็มที่ ส่วน module Reporting ที่มีแต่ query เปิดแค่ slice ก็พอ
ทั้งสองอยู่ใน solution เดียวกันได้โดยไม่มีใครขัดใคร — ตราบใดที่ทีมเขียนไว้ที่ไหนสักแห่งว่า module ไหนอยู่ชุดไหน เพราะสิ่งที่ทำลายสถาปัตยกรรมเร็วที่สุดไม่ใช่การเลือกผิด แต่คือการที่คนละคนเดาคนละแบบ
จุดที่เจ็บจริงของ modular monolith ไม่ได้อยู่ที่การแบ่ง module แต่อยู่ที่ transaction ที่คร่อม2 module ตราบใดที่ยังเป็นหน่วย deploy เดียวและฐานข้อมูลเดียว การเขียน2 module ใน transaction เดียวยังทำได้ และมันจะถูกใช้ เพราะมันง่ายและมันทำงาน
ปัญหาคือวันที่ตัดสินใจแตก module ออกเป็น service จริง code ทุกจุดที่พึ่ง transaction ร่วมนั้นจะกลายเป็นงานที่ต้องเขียนใหม่ทั้งหมด และมักไม่มีใครรู้ว่ามีกี่จุด
ทางที่แพงกว่าตอนนี้แต่ถูกกว่าตอนนั้นคือบังคับให้ข้าม module ผ่าน integration event ตั้งแต่แรก และยอมรับ eventual consistency ตั้งแต่วันที่ยังไม่มีเครือข่ายมาคั่น
เรื่องที่มักถูกข้ามคือคำว่า “ส่ง event” ใน module าร์โมโนลิธมักหมายถึงการเรียก method ของ handler ใน process เดียวกันแบบซิงโครนัส ซึ่งแปลว่าถ้า handler ฝั่ง Delivery โยน exception ผู้ใช้ที่กดสั่งอาหารจะเห็น 500 ทั้งที่ออเดอร์บันทึกสำเร็จไปแล้ว
การเปลี่ยนเป็น outbox แล้วให้ตัวส่งอ่านจากตารางเดียวกับที่บันทึกออเดอร์ แก้ปัญหานี้ได้จริง แต่ก็เปลี่ยนพฤติกรรมของระบบไปด้วย: ตั้งแต่วินาทีนั้นเป็นต้นไป Delivery จะรู้เรื่องออเดอร์ ช้ากว่า ผู้ใช้เสมอ และหน้าจอที่เคยแสดงผลทันทีต้องออกแบบใหม่
นี่ไม่ใช่ราคาของ modular monolith โดยเฉพาะ — มันคือราคาของการแยกความสามารถออกจากกัน ไม่ว่าจะแยกด้วย folder หรือด้วยเครือข่าย
คำเดียวกัน สองความละเอียด
หัวข้อที่มีชื่อว่า “คำเดียวกัน สองความละเอียด”คำว่า “vertical slice” ของ Grzybek หมายถึง Ordering ทั้งก้อน ส่วนของ Bogard หมายถึง PlaceOrder อันเดียว
ความสับสนที่เจอบ่อยที่สุดในการคุยเรื่องนี้ไม่ได้มาจากแนวคิด แต่มาจากคำ — คำว่า “vertical slice” ถูกใช้ที่สองความละเอียดที่ต่างกันมาก
Grzybek ใช้คำนี้ตอนอธิบายว่า module ควรถูกแบ่งอย่างไร: แบ่งตามความสามารถทางธุรกิจแบบ slice แนวตั้ง ไม่ใช่แบ่งตามชั้นเทคนิค เพื่อให้การเปลี่ยนแปลงที่เกิดบ่อยกระทบแค่ module เดียว ที่ความละเอียดนี้ “slice” หนึ่งอันคือ Ordering ทั้งก้อน
ส่วน Bogard ใช้คำเดียวกันที่ความละเอียดของ 1 request — slice หนึ่งอันคือ PlaceOrder อันเดียว ไม่ใช่ Ordering ทั้งก้อน
| ใครใช้คำ | 1 slice คืออะไร | เส้นแบ่งบังคับด้วยอะไร |
|---|---|---|
| Grzybek (ระดับ module) | ความสามารถทางธุรกิจหนึ่งก้อน เช่น Ordering | contract ของ module + architecture test |
| Bogard (ระดับ request) | 1 request เช่น PlaceOrder | ไม่มีอะไรบังคับ เป็นข้อตกลงของทีม |
ความต่างของช่องขวาสำคัญกว่าที่เห็น เพราะเส้นแบ่งระดับ module บังคับได้ด้วยเครื่องมือ ส่วนเส้นแบ่งระหว่าง slice บังคับไม่ได้ — ไม่มี compiler ตัวไหนห้าม slice PlaceOrder เรียก class ภายในของ slice CancelOrder สิ่งเดียวที่กันไว้คือวินัยและรีวิว
นี่คือเหตุผลที่ทีมที่ใช้ slice อย่างเดียวโดยไม่มี module มักพบว่าหลังผ่านไปสองปี slice เริ่มเรียกกันเองจนแยกไม่ออกอีก
เส้นแบ่งที่ไม่มีอะไรบังคับ คือชื่อ folder
หัวข้อที่มีชื่อว่า “เส้นแบ่งที่ไม่มีอะไรบังคับ คือชื่อ folder”แกน Clean บังคับตัวเองได้ฟรีถ้าแยกเป็น project จริง ส่วนแกน Modular Monolith ต้องมีเทสมาบังคับ
ข้อสรุปข้างบนนำมาสู่กฎที่ใช้ได้กับทั้งสามแกน: แกนไหนที่ไม่มีเครื่องมือบังคับ แกนนั้นจะรั่ว ไม่ใช่เพราะทีมไม่ตั้งใจ แต่เพราะเส้นตายมาก่อนความตั้งใจเสมอ
แกน Clean บังคับตัวเองได้ฟรีถ้าแยกเป็น project จริง เพราะไม่มี ProjectReference ก็ compile ไม่ผ่าน ส่วนแกน Modular Monolith ที่อยู่ใน project เดียวกันต้องมีเทสมาบังคับ:
// tests/Architecture/ModuleBoundaryTests.cs — NetArchTest[Fact]public void Delivery_ShouldNotDependOn_OrderingDomain(){ var result = Types.InAssembly(typeof(IOrderingModule).Assembly) .That().ResideInNamespace("FoodOrdering.Modules.Delivery") .ShouldNot().HaveDependencyOn("FoodOrdering.Modules.Ordering.Domain") .GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));}เทสแบบนี้ราคาถูกมากเมื่อเทียบกับสิ่งที่มันกัน — มันเปลี่ยนกำแพงจาก “ข้อตกลงที่พูดกันในสแตนด์อัป” เป็น “build แดง” และมันคือความต่างระหว่าง module าร์โมโนลิธจริงกับ folder ที่ตั้งชื่อว่า Modules/
แต่ต้องรู้ขอบเขตของมันด้วย เทสแบบนี้อ่าน dependency จาก metadata ของ assembly ซึ่งเห็นเฉพาะการอ้างถึงชนิดโดยตรง
มันไม่เห็นการเรียกผ่าน reflection ไม่เห็นชนิดที่ถูกดึงออกจาก DI container ด้วย GetRequiredService<T>() ในที่ที่ T มาจาก generic parameter และไม่เห็น query ที่เขียนเป็น string SQL ดิบไปยังตารางของอีก module ช่องทางเหล่านี้เป็นทางรั่วที่เทสจับไม่ได้ทั้งหมด
สิ่งที่จับได้จริงคือการแยก schema ในฐานข้อมูลแล้วให้ผู้ใช้ของแต่ละ module มีสิทธิ์เฉพาะ schema ของตัวเอง ซึ่งย้ายการบังคับจาก compile time ไปอยู่ที่ฐานข้อมูล และเป็นด่านที่ reflection ข้ามไม่ได้
แล้วเลือกอย่างไร
หัวข้อที่มีชื่อว่า “แล้วเลือกอย่างไร”ตอบเป็นราย module ด้วยข้อมูลที่ทีมมีอยู่แล้ว ไม่ใช่ด้วยการเดาว่าระบบนี้จะโตแค่ไหน
คำถามที่ตอบได้คือคำถามราย module และตอบได้ด้วยข้อมูลที่ทีมมีอยู่แล้ว ไม่ต้องเดาอนาคต
| ถาม module นี้ว่า | ถ้าใช่ ให้เปิด |
|---|---|
| มีความสามารถทางธุรกิจอื่นในระบบที่ไม่ควรแตะตารางของ module นี้ไหม | Modular Monolith |
| งานหนึ่งชิ้นใน module นี้ทำให้ต้องเปิด file เกิน3 folder เป็นประจำไหม | Vertical Slice |
| มีกฎธุรกิจที่ถ้าเขียนผิดแล้วเสียเงินจริง และกฎนั้นถูกใช้จากมากกว่าหนึ่งทางเข้าไหม | Clean Architecture |
| ตอบว่าไม่ทั้งสามข้อ | ไม่ต้องเปิดอะไรเลย และนั่นเป็นคำตอบที่ถูกต้อง |
สังเกตว่าไม่มีข้อไหนถามว่า “ระบบนี้จะโตแค่ไหน” เพราะคำตอบของคำถามนั้นเดาแทบไม่เคยถูก และเป็นเหตุผลที่ทีมจำนวนมากจ่ายค่าผ่านทางของสามแกนตั้งแต่วันแรกให้กับระบบที่สุดท้ายมีผู้ใช้สามสิบคน
สิ่งที่เดาถูกกว่าคือของที่วัดได้วันนี้ — จำนวนทีมที่แก้ code ก้อนเดียวกัน จำนวน folder ต่อหนึ่งงาน และจำนวนทางเข้าที่เรียกกฎข้อเดียวกัน
ข้อสุดท้ายที่ต้องพูดให้ตรงคือ modular monolith ไม่ได้ทำให้การแตกเป็น microservice เป็นเรื่องฟรี มันทำให้ การหาเส้นตัด เป็นเรื่องที่ทำไปแล้ว ซึ่งเป็นส่วนที่ยากที่สุดจริง
แต่ส่วนที่เหลือ — เครือข่ายที่ล้มเหลวได้, การ deploy หลายชิ้นที่ต้องเข้ากันได้, ข้อมูลที่ไม่มี transaction ร่วมอีกต่อไป, และการ debug หนึ่งคำขอที่วิ่งข้ามหลาย service — ยังต้องจ่ายเต็มจำนวนอยู่ดี
บทความ Microservice Trade-Offs ของ Martin Fowler ไล่ราคาส่วนนั้นไว้ครบ และคอร์ส แยก Bounded Context แล้วเชื่อมกลับ พาลงมือทำจริงตั้งแต่ module จนถึงการแตกออกหนึ่งก้อน
สามสวิตช์ที่ตอบคนละคำถาม เปิดพร้อมกันได้ทั้งแปดชุด และปิดหมดก็เป็นคำตอบที่ถูกต้องสำหรับหลายระบบ
สามคำในหัวข้อไม่ใช่สามตัวเลือกในเมนูเดียวกัน มันคือสามสวิตช์ที่ตอบคนละคำถาม — module ของใคร file อยู่ที่ไหน และลูกศรชี้ทางไหน
เปิดพร้อมกันได้ทั้งแปดชุด ปิดหมดก็เป็นคำตอบที่ถูกต้องสำหรับหลายระบบ และคำตอบที่ดีที่สุดมักไม่เหมือนกันภายในระบบเดียวกันด้วยซ้ำ
สิ่งที่ต้องระวังมีอยู่สองอย่างเท่านั้น: อย่าจ่ายค่าผ่านทางของแกนที่ module นั้นไม่ได้ใช้ และอย่าประกาศเส้นแบ่งที่ไม่มีอะไรบังคับ เพราะเส้นแบ่งแบบนั้นคือชื่อ folder ไม่ใช่สถาปัตยกรรม
อ่านต่อบนไซต์นี้:
- MVC + Service Layer กับ Clean + DDD เทียบสองชุดในนี้แบบลงลึกทีละ scenario
- The Clean Architecture ฉบับของ Robert C. Martin เอง
- Microservice Trade-Offs สำหรับขั้นถัดจาก module
- Vertical Slices ใน DevIQ สำหรับที่มาของคำและตัวอย่างโครง folder
บทความนี้เป็นบทความต้นฉบับของ Kaen ที่สังเคราะห์จากงานสามชิ้น
- Modular Monolith: A Primer (Kamil Grzybek, 2 ธันวาคม 2019) พร้อมภาคต่อเรื่อง Architecture Enforcement
- Vertical Slice Architecture (Jimmy Bogard, 19 เมษายน 2018)
- The Clean Architecture (Robert C. Martin, 13 สิงหาคม 2012)
ประโยคที่ยกมาเป็นภาษาอังกฤษคือประโยคของผู้เขียนต้นทางเอง ส่วนการจัดสามแกนเป็นแปดชุดและคำแนะนำราย module เป็นการเรียบเรียงของ Kaen ไม่ใช่ข้อเสนอของผู้เขียนคนใดคนหนึ่ง