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

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 นี้​ควร​เปิด​กี่​แกน” — คำ​ตอบ​ต่าง​กัน​ได้​ภายใน​ระบบ​เดียวกัน

domain ที่​ใช้​ตลอด​บทความ

ทุก block code ใน​หน้า​นี้​อยู่​บน domain FoodOrdering เดียว​กับ​ที่​คอร์ส​ทั้ง​สาย​ใช้ — ระบบ​สั่ง​อาหาร​ที่​มี Order เป็น​ตัวเอก และ​มี Delivery เป็น​ความ​สามารถ​อีก​ก้อน​ที่​อยู่​ใน​ระบบ​เดียวกัน

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

ด้าน​บน​มี​สวิตช์ ระดับ​ผู้​อ่าน อยู่​หนึ่ง​ตัว 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 Slicefile ของ 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 จ่าย​ก่อน column ได้

ทุก​ชุด​ใน​กล่อง​ข้าง​บน​มี​ของ​ที่​ได้ ถ้า​อ่าน​แต่ column ซ้าย​จะ​รู้สึก​ว่า​เปิด​ครบ​สาม​แกน​ดี​ที่สุด​เสมอ ซึ่ง​ไม่​จริง — ราคา​ของ​แกน​หนึ่ง​จ่าย​เป็น​เวลา​ของ​คน​ทั้ง​ทีม​ทุก​วัน ส่วน​ผล​ตอบแทน​ของ​มัน​มา​เป็น​ก้อน​ตอน​ที่​ระบบ​โต​ถึง​จุด​หนึ่ง​เท่านั้น และ​ถ้า​ไม่​ถึง​จุด​นั้น ก็​ไม่มี​ผล​ตอบแทน

ทั้ง​สาม​ก้อน​ทำงาน​เดียวกัน​ทุก​ประการ สิ่ง​ที่​ต่าง​กัน​คือ​ที่​อยู่​ของ​กฎ ไม่ใช่​ปริมาณ code

เพื่อ​ให้​เห็น​ว่า​สิ่ง​ที่​ต่าง​กัน​คือ ที่​อยู่​ของ​กฎ ไม่ใช่​ปริมาณ code ทั้ง​สาม​ก้อน​ข้าง​ล่าง​ทำงาน​เดียวกัน​ทุก​ประการ: รับคำ​สั่ง​ซื้อ ตรวจ​ว่า​ตะกร้า​ไม่​ว่าง บันทึก แล้ว​คืน​รหัส​ออเดอร์

ทั้ง feature อยู่​ใน folder เดียว และ handler คุย​กับ DbContext ตรง ๆ ไม่มี​ชั้น​คั่น:

// src/Features/PlaceOrder/Handler.cs — ทั้ง slice อยู่ใน folder เดียวกับ Endpoint.cs
// Order ฝั่งนี้เป็น anemic model (property get/set ล้วน) id จึงเป็น Guid เปล่า ไม่ใช่ value object
public 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 ที่​สอง​สร้าง​ออเดอร์​ได้​ด้วย (เช่น สั่ง​ซ้ำ​จากออเดอร์​เก่า) กฎ​ข้อ​นี้​ต้อง​ถูก​เขียน​ใหม่​อีก​ครั้ง และ​ไม่มี​อะไร​บอก​ได้​ว่า​สอง​ที่​นั้น​เขียน​ตรง​กัน

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 เรียกได้แค่ผ่าน interface
var summary = await _ordering.GetSummaryAsync(cmd.OrderId, ct)
?? throw new InvalidOperationException("ไม่พบออเดอร์ที่อ้างถึง");

สังเกต​ว่า​เนื้อ​ใน​ของ handler ไม่​เปลี่ยน​เลย​สัก​บรรทัด สิ่ง​ที่​เพิ่ม​เข้า​มา​คือ กำแพง​รอบ moduleDelivery เรียก 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)ความ​สามารถ​ทาง​ธุรกิจ​หนึ่ง​ก้อน เช่น Orderingcontract ของ module + architecture test
Bogard (ระดับ request)1 request เช่น PlaceOrderไม่มี​อะไร​บังคับ เป็น​ข้อ​ตกลง​ของ​ทีม

ความ​ต่าง​ของ​ช่อง​ขวา​สำคัญ​กว่า​ที่​เห็น เพราะ​เส้น​แบ่ง​ระดับ module บังคับ​ได้​ด้วย​เครื่องมือ ส่วน​เส้น​แบ่ง​ระหว่าง slice บังคับ​ไม่​ได้ — ไม่มี compiler ตัว​ไหน​ห้าม slice PlaceOrder เรียก class ภายใน​ของ slice CancelOrder สิ่ง​เดียว​ที่​กัน​ไว้​คือ​วินัย​และ​รีวิว

นี่​คือ​เหตุผล​ที่​ทีม​ที่​ใช้ slice อย่าง​เดียว​โดย​ไม่มี module มัก​พบ​ว่า​หลัง​ผ่าน​ไป​สอง​ปี slice เริ่ม​เรียก​กันเอง​จน​แยก​ไม่​ออก​อีก

แกน 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 ไม่ใช่​สถาปัตยกรรม

อ่าน​ต่อ​บน​ไซต์​นี้:

ที่มา​ของ​แนวคิด​ใน​บทความ​นี้

บทความ​นี้​เป็น​บทความ​ต้นฉบับ​ของ Kaen ที่​สังเคราะห์​จาก​งาน​สาม​ชิ้น

ประโยค​ที่​ยก​มา​เป็น​ภาษา​อังกฤษ​คือ​ประโยค​ของ​ผู้​เขียน​ต้นทาง​เอง ส่วน​การ​จัด​สาม​แกน​เป็น​แปด​ชุด​และ​คำ​แนะนำ​ราย module เป็นการ​เรียบเรียง​ของ Kaen ไม่ใช่​ข้อ​เสนอ​ของ​ผู้​เขียน​คน​ใด​คน​หนึ่ง