MVC + Service Layer กับ Clean + DDD: เลือกอย่างไรให้ไม่จ่ายเปล่า
คำถามที่ได้ยินบ่อยที่สุดคือ “ควรใช้ MVC, Clean Architecture หรือ DDD ดี” — คำถามนี้ตอบไม่ได้ เพราะสามคำนี้ไม่ได้อยู่มิติเดียวกัน
- MVC — pattern ที่แยกการแสดงผลออกจากข้อมูล
- Clean Architecture — กฎเรื่อง ทิศทางของ dependency ว่า code วงนอกต้องชี้เข้าหาวงในเสมอ
- DDD — ยุทธวิธีสร้าง ตัว domain model เอง
ทั้งสามอยู่ด้วยกันได้สบาย ระบบ Clean ที่ชั้นนอกสุดเป็น ASP.NET Core MVC ก็ยังเป็น Clean เต็มตัว
หน้านี้จึงไม่เทียบสามอย่างเป็นตัวเลือกคู่แข่ง แต่เทียบสองโครงนี้:
- “MVC + Service Layer” — controller เรียก service แล้ว service คุยกับ EF Core ตรง ๆ
- “Clean + DDD” — แยกสี่ชั้น และย้ายกฎเข้าไปอยู่ใน entity
Clean/DDD ไม่ได้ลดความซับซ้อน — มันย้ายความซับซ้อนไปอยู่ในที่ที่เทสง่ายที่สุด แล้วเก็บค่าผ่านทางเป็นจำนวน file คำถามจึงไม่ใช่ “อันไหนดีกว่า” แต่คือ “domain ของคุณมีกฎมากพอจะคุ้มค่าผ่านทางไหม”
ทุก block code ในหน้านี้อยู่บน domain เดียวกันคือ FoodOrdering — ระบบสั่งอาหาร ที่มี Order เป็นตัวเอก ลูกค้าเลือกรายการจากเมนูของร้าน ระบบตัดเงิน แล้วออเดอร์เดินสถานะไปจนถึงมือคนสั่ง กฎที่จะใช้เดินเรื่องมีสองข้อ: ราคาต้องมาจากเมนูจริงของร้าน ไม่ใช่จากที่ client ส่งมา และ ยอดรวมต้องถึงยอดขั้นต่ำของร้าน
ถ้าอยากเห็น code เต็มของฝั่ง Clean + DDD ทั้งระบบ ไม่ใช่แค่ชิ้นที่ยกมาเทียบในหน้านี้ ให้ตามไปที่คอร์ส Clean Architecture ด้วย .NET สำหรับโครงสี่ชั้น และคอร์ส DDD ใน code จริง สำหรับการเติมกฎเข้าไปใน domain
หน้านี้มีสวิตช์สองตัวอยู่ด้านบน
สวิตช์ที่หนึ่ง — ระดับผู้อ่าน L1 เห็นเส้นทางหลัก คำอธิบายภาษาคน และ block code C# ครบทุกก้อน (code ไม่เคยถูกซ่อนที่ระดับไหนเลย เพราะย่อหน้าที่อธิบายมันจะกลายเป็นรูทันที) สิ่งที่ระดับสูงกว่าเปิดเพิ่มเกือบทั้งหมดอยู่ในโหมด เทียบสองทาง:
- L2 (ค่าเริ่มต้น) — เปิดหมายเหตุของ hop ในผังเพิ่มอีกชุด รวมถึงป้าย ⭯ กลับทิศ ทั้งสองอัน แล้วเพิ่มเหตุผลที่ aggregate อ้างถึงกันด้วย id, ตารางค่าเจรจาระหว่าง domain กับ EF Core และ CQRS ฉบับเบาที่สุด
- L3 — เปิดที่เหลือทั้งหมด ทั้งหมายเหตุชั้นในสุดของผัง และเนื้อหาอีกห้าย่อหน้า: dual-write ที่ไม่มีสถาปัตยกรรมไหนแก้ให้, การกันข้อความซ้ำสองทางนั้นทางไหนแข็งแรงกว่า, outbox กับ event ที่หายเงียบ ๆ, ความลึกจริงของการย้ายไป Dapper และการที่ EF ข้าม
Order.Place()ตอนโหลดข้อมูลกลับขึ้นมา
ส่วนในโหมด ย้ายทีละขั้น L2 เปิดเพิ่มแค่ขั้นที่ให้ผลตอบแทนสูงสุด และ L3 ไม่เปิดอะไรเพิ่มเลย
สวิตช์ที่สอง — โหมด โหมด “เทียบสองทาง” คือการเลือกว่าจะอยู่ตรงไหน ส่วนโหมด “ย้ายทีละขั้น” คือบันไดสำหรับคนที่ตัดสินใจแล้วว่าจะย้าย เลือกอ่านเฉพาะที่ตรงกับสถานการณ์ของคุณได้เลย ไม่ต้องอ่านทั้งหมด
ตัวละครสองฝั่ง
หัวข้อที่มีชื่อว่า “ตัวละครสองฝั่ง”ครึ่งหนึ่งของค่าผ่านทางที่ธีสิสพูดถึงคือ “จำนวนชื่อที่ต้องจำ” ล้วน ๆ
ก่อนจะเทียบเส้นทางของ request ต้องรู้ก่อนว่าแต่ละฝั่งมีใครอยู่บ้าง
ฝั่ง MVC + Service Layer มีของให้จำหกอย่าง และทุกอย่างอยู่ใน project เดียว:
| Class | บทบาท |
|---|---|
OrdersController | รับ HTTP, validate ด้วย ModelState, เรียก service แล้วส่งผลลัพธ์กลับ |
OrderService | business rule อยู่ที่นี่ทั้งหมด และขึ้นกับ FoodOrderingDbContext โดยตรง |
Order | anemic — property แบบ get/set ล้วน ไม่มี method ที่บังคับกฎอะไรเลย |
FoodOrderingDbContext | EF Core — OrderService เรียกใช้ตรง ๆ ไม่มีอะไรคั่นกลาง |
IStripeClient / IEmailSender | interface ของบริการภายนอก ทีมประกาศไว้เองเพราะไม่งั้นเทสไม่ได้ |
| SQL Server | ฐานข้อมูลจริง |
แถวรองสุดท้ายคือแถวที่มักถูกเล่าผิด ทีม MVC ที่ทำงานเป็นย่อมมี interface คั่นบริการภายนอกอยู่แล้ว
IStripeClient กับ IEmailSender ไม่ได้โผล่มาเพราะใครอ่านหนังสือสถาปัตยกรรม แต่โผล่มาเพราะถ้าไม่มีมัน เทสทุกตัวจะยิงเงินจริงและส่งเมลจริง
ความต่างที่แท้จริงระหว่างสองฝั่งจึงไม่ใช่ “มี interface หรือไม่มี” แต่คือ ฝั่ง MVC ไม่มี interface คั่น persistence — OrderService ถือ FoodOrderingDbContext ไว้ในมือโดยตรง เขียน LINQ ได้ทุกบรรทัดที่อยากเขียน และนั่นคือจุดที่ผลลัพธ์จะต่างกันตลอดทั้งหน้านี้
ฝั่ง Clean + DDD มีของเดิมทั้งหมดนั้น แต่กระจายอยู่ใน4 project และเพิ่มชื่อใหม่เข้ามาอีกชุด:
| ชั้น | ตัวละคร | บทบาท |
|---|---|---|
| Presentation | OrdersController · SubscriptionCronJob : BackgroundService · OrderQueueConsumer | สามทางเข้า วิ่งเข้าหาจุดเดียวกันทั้งหมด |
| Application | PlaceOrderCommand · PlaceOrderHandler | รับ command แล้วประสานงาน ไม่มี rule ของตัวเอง |
| Application (port) | IOrderRepository · IMenuCatalog · IPaymentGateway · IDomainEventDispatcher · ICurrentUser | interface ที่วงในประกาศว่า “ฉันต้องการของแบบนี้” ส่วนใหญ่ยาวไม่กี่บรรทัด |
| Domain | Order (aggregate root) · OrderLine · Money · Quantity · OrderPlaced | กฎธุรกิจอยู่ที่นี่ ไม่รู้จัก EF Core หรือ ASP.NET เลยสักบรรทัด |
| Infrastructure | EfOrderRepository · StaticMenuCatalog · StripePaymentGateway · SmtpEmailSender · FoodOrderingDbContext | ของจริงที่ต่อกับโลกภายนอก และเป็นที่เดียวที่มี SQL |
ในตารางนี้มีสามเส้นที่ต้องเห็นให้ชัดที่สุด:
EfOrderRepository → IOrderRepositoryStripePaymentGateway → IPaymentGatewayStaticMenuCatalog → IMenuCatalog
ทั้ง3 class อยู่ชั้น Infrastructure ซึ่งเป็นวงนอกสุด แต่ลูกศรของมัน (คือ using และ ProjectReference ที่ compiler เห็น) ชี้ เข้าหา interface ที่ชั้น Application เป็นผู้ประกาศ ไม่ใช่กลับกัน
นี่คือจุดกลับทิศ — และเป็นเหตุผลเดียวที่ PlaceOrderHandler เทสได้โดยไม่มีฐานข้อมูล ถ้าคุณจำอะไรจากหน้านี้ได้อย่างเดียว ให้จำสามเส้นนี้
POST /orders — เดินทีละ hop
หัวข้อที่มีชื่อว่า “POST /orders — เดินทีละ hop”เส้นทางเดียวกัน สองรูปร่าง — ฝั่งหนึ่งอยู่ใน method เดียว อีกฝั่งย้ายกฎเข้าไปอยู่ใน
Order
POST /orders — เดินทีละ hop
ขั้นที่ 1 จาก 13MVC + Service Layer · 10 hop
- Web
OrdersController.Create(CreateOrderDto) - Web
validate ด้วย ModelState - Service
_orderService.PlaceOrderAsync(dto) - Service
_db.Restaurants.FindAsync(dto.RestaurantId)service ยิง EF Core ตรง ไม่มีอะไรคั่น - Service
รวมราคาจากที่ client ส่งมาราคามาจาก request — ไม่มีใครยืนยันกับเมนูจริง - Service
if (total < restaurant.MinimumOrder) throwกฎอยู่ใน service ไม่ได้อยู่ใน entity - Service
new Order { Status = "Placed", Total = total }ไม่มีอะไรกันไม่ให้สร้าง Order ที่ผิดกฎ - Service
_stripe.ChargeAsync(...)ตัดเงินก่อนบันทึก — dual-write - Service
_db.Orders.Add(order) → SaveChangesAsync()บันทึกสำเร็จ - Service
_email.SendAsync(...)อยู่ใน request path — SMTP ล่ม = ผู้ใช้เห็น 500 ทั้งที่ order สำเร็จแล้ว
Clean + DDD · 13 hop
- Web
OrdersController.Create(PlaceOrderRequest) - Web
map → PlaceOrderCommandDTO ของ API ไม่รั่วเข้า domain - Application
PlaceOrderHandler.Handle - Application
_menu.GetItemsAsync(...) ผ่าน IMenuCatalogราคามาจากเมนูจริง ไม่ใช่จากที่ client ส่งมา - Infrastructure
StaticMenuCatalog implement IMenuCatalog⭯ กลับทิศลูกศรกลับทิศ — code อยู่วงนอก แต่ชี้เข้าหา interface ที่วงในประกาศ - Domain
Order.Place(new OrderId(Guid.NewGuid()), items, minimumOrder)invariant อยู่ใน aggregate → InvalidOperationException - Domain
aggregate เก็บ OrderPlaced เข้า collectionยังไม่ publish - Application
_payments.ChargeAsync(order.Total) ผ่าน IPaymentGatewayตัดเงินก่อนบันทึกเหมือนฝั่ง MVC — port ไม่ได้แก้ dual-write ให้ - Application
_orders.SaveAsync(order, ct)บันทึกสำเร็จ - Infrastructure
EfOrderRepository implement IOrderRepository⭯ กลับทิศลูกศรกลับทิศเส้นที่สอง - Infrastructure
FoodOrderingDbContext → SQLจุดเดียวในระบบที่มี SQL — และเกิดตอนบันทึก ไม่ใช่ตอนดึงเมนู - Application
หลังบันทึกสำเร็จ → dispatch OrderPlacedลำดับสำคัญ ห้าม dispatch ก่อนบันทึก - Infrastructure
OrderPlacedHandler → ส่งเมล หรือเข้า outbox
POST /orders สองฝั่ง — ฝั่งซ้าย 10 hop ฝั่งขวา 13 hop ป้าย ⭯ กลับทิศ คือจุดที่ code อยู่วงนอกแต่ลูกศรชี้เข้าหาวงใน ซึ่งเป็นทั้งหมดของคำว่า Cleancode ข้างล่างคือเนื้อในของขั้นที่สำคัญที่สุดในผังข้างบน
ฝั่ง MVC ทั้งเส้นทางอยู่ใน method เดียว อ่านจากบนลงล่างจบในหน้าจอเดียว ซึ่งเป็นข้อดีจริงที่จะพูดถึงอีกหลายครั้งในหน้านี้:
// OrderService.cs — ฝั่ง MVC ทั้งหมดอยู่ใน method เดียวpublic async Task<OrderDto> PlaceOrderAsync(CreateOrderDto dto){ var restaurant = await _db.Restaurants.FindAsync(dto.RestaurantId) ?? throw new NotFoundException();
var total = dto.Lines.Sum(l => l.UnitPrice * l.Qty); // ราคามาจาก client if (total < restaurant.MinimumOrder) throw new InvalidOperationException("ยอดไม่ถึงขั้นต่ำของร้าน");
var order = new Order { RestaurantId = restaurant.Id, Total = total, Status = "Placed" };
await _stripe.ChargeAsync(dto.CustomerId, total); // ← ก่อนบันทึก _db.Orders.Add(order); await _db.SaveChangesAsync(); await _email.SendAsync(dto.Email, "ยืนยันคำสั่งซื้อ"); // ← ใน request path
return OrderDto.From(order);}code ก้อนเดียวนี้ทำงานได้จริงและส่งงานได้เร็ว แต่มีสามอย่างซ่อนอยู่:
- ราคาเชื่อ client —
dto.Lines.Sum(...)เชื่อราคาที่ client ส่งมาโดยไม่มีใครยืนยันกับเมนูจริงเลย - กฎอยู่ผิดที่ — กฎ “ยอดต้องถึงขั้นต่ำ” อยู่ใน service ไม่ได้อยู่ใน
Orderใครก็ตามที่เขียนnew Order { ... }ที่อื่นในระบบ สร้างออเดอร์ที่ผิดกฎได้ทันทีโดยไม่มีอะไรกัน - ลำดับ side effect — การตัดเงินเกิดขึ้น ก่อน บันทึก ส่วนการส่งเมลเกิดขึ้น ใน request path SMTP ช้าคือผู้ใช้รอ SMTP ล่มคือผู้ใช้เห็น 500 ทั้งที่ออเดอร์บันทึกสำเร็จไปแล้ว
ฝั่ง Clean + DDD ย้ายกฎข้อที่สองเข้าไปอยู่ใน Order แต่ก่อนจะดู code นั้นได้ ต้องแวะที่ Money ก่อนหนึ่งจังหวะ เพราะฝั่งนี้ยอดเงินไม่ใช่ decimal เปลือย ๆ แล้ว มันเป็น value object และ record ของ C# ไม่ได้แถม < กับ > มาให้:
// FoodOrdering.Domain/Orders/Money.cs — บทที่ 2 ของ clean-architecture-dotnet ให้ guard, Thb,// operator + และ operator * มาแล้ว สองตัวข้างล่างนี้เพิ่มสำหรับบทความนี้// การเทียบสองค่าต้องเป็นสกุลเดียวกันเสมอ ไม่งั้นตัวเลขที่เทียบไม่มีความหมายpublic sealed record Money(decimal Amount, string Currency){ public static Money Thb(decimal amount) => new(amount, "THB");
public static bool operator <(Money left, Money right) { if (left.Currency != right.Currency) throw new InvalidOperationException("เทียบเงินคนละสกุลไม่ได้"); return left.Amount < right.Amount; }
public static bool operator >(Money left, Money right) => right < left;}โอเปอเรเตอร์สองตัวนี้คือผลตอบแทนของ value object ที่ได้มาตั้งแต่ยังไม่ทันเขียน Order เลยด้วยซ้ำ กฎที่ว่า “เทียบเงินคนละสกุลไม่ได้” อยู่ใน ชนิดข้อมูล ไม่ได้อยู่ในความจำของนักพัฒนา
พูดให้ตรงคือวันนี้กิ่งที่โยน exception นั้นยังไม่มีทางถูกเรียก เพราะ guard ใน constructor ของ Money ที่บทที่ 2 เขียนไว้ปฏิเสธทุกสกุลที่ไม่ใช่ THB ตั้งแต่ตอนสร้างอยู่แล้ว ประโยชน์ของมันคือวันที่ร้านเปิดรับสกุลที่สอง กฎจะถูกบังคับให้เองโดยไม่มีใครต้องนึกออก
ส่วนฝั่ง MVC ที่ใช้ decimal เปลือย ๆ กฎข้อนี้ไม่ได้หายไปไหน มันแค่ไม่มีที่อยู่ใน code และย้ายไปอยู่ในหัวคนแทน ซึ่งลืมได้ตอนรีวิว PR ตอนตีสอง
ทีนี้ถึง Order ตัวจริง:
// FoodOrdering.Domain/Orders/Order.cs — กฎอยู่ในนี้ ไม่มี code ตรงไหนสร้าง Order ที่ผิดกฎขึ้นมาใหม่ได้// ต่อยอดจาก ddd-in-code บทที่ 6 — เพิ่ม parameter ที่สามสำหรับบทความนี้public static Order Place(OrderId id, IReadOnlyList<OrderLine> items, Money minimumOrder){ if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items); if (order.Total < minimumOrder) throw new InvalidOperationException("ยอดไม่ถึงขั้นต่ำของร้าน");
order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); return order;}block นี้ ต่อยอดจาก ddd-in-code บทที่ 6 — คอร์สเขียนไว้เป็น Order.Place(OrderId id, IReadOnlyList<OrderLine> items) รับ2 parameter ส่วนบทความนี้เพิ่มตัวที่สามเป็น Order.Place(OrderId id, IReadOnlyList<OrderLine> items, Money minimumOrder) เพื่อให้กฎยอดขั้นต่ำมีที่อยู่ใน domain
สังเกตว่า method นี้เป็น constructor ทางเดียวที่เปิดให้ใช้ — new Order(...) เป็น private — ผลคือ ไม่มี code ตรงไหนในระบบสร้าง Order ที่ผิดกฎขึ้นมาใหม่ได้เลย ไม่ว่าจะเรียกจาก controller, จาก cron หรือจาก migration script
(ส่วนออเดอร์ที่ ORM โหลดกลับขึ้นมาจากแถวเดิมเป็นคนละเรื่อง เพราะตอนนั้นไม่มีใครเรียก factory ตัวนี้)
และสังเกตต่อว่า OrderPlaced ถูกเก็บเข้า collection ภายในเฉย ๆ ยังไม่มีใครถูกแจ้งอะไรทั้งนั้น
parameter ตัวที่สามรับ Money minimumOrder ไม่ใช่ Restaurant ทั้งก้อน ทั้งที่ยอดขั้นต่ำเป็นสมบัติของร้าน
เพราะกฎที่ไซต์นี้สอนไว้เองใน DDD Patterns บทที่ 14 บอกว่าความสัมพันธ์ระหว่าง aggregate ควรอ้างอิงด้วย id ของอีกฝั่งเท่านั้น ไม่ใช่ถืออ็อบเจ็กต์ทั้งก้อนไว้
การส่ง snapshot ของค่า เข้ามาแทนทำให้ Order ไม่ต้องรู้จัก Restaurant เลย และนั่นคือเหตุผลที่เทสของกฎนี้ในตอนท้ายหน้าไม่ต้องมี mock สักตัว
ส่วนคนที่ประสานทั้งหมดเข้าด้วยกันคือ handler ในชั้น Application ซึ่งไม่มีกฎของตัวเองเลยสักข้อ:
// FoodOrdering.Application/Orders/PlaceOrderHandler.cs — orchestrate อย่างเดียว ไม่มี rulepublic async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct){ // ทั้งราคาและยอดขั้นต่ำมาจากร้านจริง ไม่ใช่จากที่ client ส่งมา var items = await _menu.GetItemsAsync(cmd.RestaurantId, cmd.Items, ct); var minimumOrder = await _menu.GetMinimumOrderAsync(cmd.RestaurantId, ct);
// ต่อยอดจาก ddd-in-code บทที่ 6 — เพิ่ม parameter ที่สามสำหรับบทความนี้ var order = Order.Place(new OrderId(Guid.NewGuid()), items, minimumOrder); // กฎทั้งหมดอยู่บรรทัดนี้
await _payments.ChargeAsync(order.Total, ct); // ← ยังอยู่ก่อนบันทึกเหมือนฝั่ง MVC await _orders.SaveAsync(order, ct); // ① transaction ปิดตรงนี้ await _dispatcher.DispatchAsync(order.DomainEvents, ct); // ② หลังบันทึกสำเร็จเท่านั้น
return order.Id;}เฉพาะใน block นี้มีสามจุดที่กว้างกว่าที่คอร์สเขียนไว้ และตั้งใจให้กว้าง:
IMenuCatalogในคอร์สมีแค่GetPriceAsync(ProductId, ct)ที่นี่ดึงทั้งรายการและยอดขั้นต่ำของร้านมาด้วยPlaceOrderCommandในคอร์สมีแค่Itemsที่นี่พกRestaurantIdมาด้วย- parameter ตัวที่สามของ
Order.Placeที่บอกไว้แล้วข้างบน
ทั้งสามอย่างมาจากเหตุผลเดียวกันคือกฎสองข้อที่หน้านี้ใช้เดินเรื่องเป็นกฎ “ของร้านนี้” ไม่ใช่กฎลอย ๆ (สองบรรทัดแรกจึงเป็น hop เดียวกันในผังข้างบน เพราะวิ่งผ่าน port ตัวเดียวกัน)
ที่เหลือรูปร่างตรงกับคอร์ส รวมถึงสองบรรทัดสุดท้ายที่สำคัญที่สุด: OrderPlaced ถูกกระจายออกไป หลัง บันทึกสำเร็จเท่านั้น ถ้าสลับสองบรรทัดนี้แล้ว SaveAsync ล้มเหลว ระบบจะส่งเมลยืนยันออเดอร์ที่ไม่เคยมีอยู่จริง
นับตามผังข้างบนแบบขั้นต่อขั้น ฝั่ง MVC จบใน 10 hop ส่วนฝั่ง Clean ใช้ 13 hop ต่างกันสามขั้น
ขั้นที่ฝั่ง Clean เพิ่มเข้ามาโดยไม่มีคู่ในฝั่ง MVC มีห้าขั้น ในนั้นเป็นลูกศรกลับทิศสองเส้น:
StaticMenuCatalogมาต่อIMenuCatalog— กลับทิศเส้นที่หนึ่งEfOrderRepositoryมาต่อIOrderRepository— กลับทิศเส้นที่สอง- aggregate เก็บ
OrderPlacedเข้า collection เป็นขั้นของตัวเอง FoodOrderingDbContext → SQLแยกออกจากขั้นSaveAsync- การกระจาย event แยกออกจากจังหวะบันทึก
ส่วนที่หักกลบไปคือสามขั้นของฝั่ง MVC — รวมราคา เช็กยอดขั้นต่ำ แล้วสร้างออเดอร์ — ที่ฝั่ง Clean ยุบเหลือ Order.Place ขั้นเดียว ห้าขั้นที่เพิ่ม ลบสองขั้นที่หายไปจากการยุบ จึงเหลือต่างกันสามขั้นพอดี
และให้สังเกตว่าผังเดินผ่านลูกศรกลับทิศแค่สองเส้นจากสามเส้นที่ตารางข้างบนบอกให้จำ — StripePaymentGateway ไม่ได้มีขั้นเป็นของตัวเองในผัง มันถูกยุบรวมอยู่ในขั้นที่ handler เรียก IPaymentGateway ขั้นเดียวกัน
ผลของเรื่องนี้บนจอคือป้าย ⭯ กลับทิศ ในผังมีสองอัน ไม่ใช่สามอัน — และป้ายนั้นเป็นรายละเอียดของระดับ L2 ขึ้นไป ที่ระดับ L1 ป้ายกลับทิศจึงหายไปทั้งคู่ เหลือไว้แค่ป้าย บันทึกสำเร็จ ที่ขั้นปิด transaction ของทั้งสองฝั่ง กับหมายเหตุชุดที่ติดระดับ L1 เอาไว้
จำนวนชนิดข้อมูลที่ต้องเปิดอ่านเพื่อเข้าใจเส้นทางนี้ ถ้านับด้วยกฎเดียวกันทั้งสองฝั่งคือ “ตัวที่ถือพฤติกรรมหรือ dependency ของเส้นทาง” จะได้ 6 ต่อ 9
- ฝั่ง MVC หกตัว —
OrdersController,OrderService,Order,FoodOrderingDbContext,IStripeClient,IEmailSender - ฝั่ง Clean เก้าตัว —
OrdersController,PlaceOrderCommand,PlaceOrderHandler,Order,OrderPlacedและ port อีกสี่ตัว (IMenuCatalog,IOrderRepository,IPaymentGateway,IDomainEventDispatcher) ที่แต่ละตัวยาวไม่กี่บรรทัด
กฎนี้ตัดของสองชนิดเดียวกันออกจากทั้งสองฝั่ง — DTO ที่ขอบอย่าง CreateOrderDto กับ OrderDto ของฝั่งซ้าย และชนิดข้อมูลย่อยใน domain อย่าง OrderId, OrderLine, Money ของฝั่งขวา ถ้านับทุกชื่อที่โผล่ใน block code จริง ๆ ตัวเลขจะขยับขึ้นทั้งคู่
หกต่อเก้าไม่ใช่เหวลึกอย่างที่คำว่า “4 project” ชวนให้คิด ความต่างที่รู้สึกได้จริงคือ6 file ใน project เดียว เทียบกับ9 file ที่กระจายอยู่ใน4 project
ส่วนตัวเลขที่กลับทิศคือจำนวน mock ที่ต้องเตรียมเพื่อเทสกฎยอดขั้นต่ำ: 2 ต่อ 0 — และฝั่งซ้ายยังต้องยกฐานข้อมูล in-memory ขึ้นมาอีกก้อนหนึ่งซึ่งไม่นับเป็น mock แต่ก็ไม่ฟรี
Clean/DDD จ่ายไปหลาย class และหลาย hop เพื่อแลกกับการเทส business rule ได้โดยไม่ต้องมี DB — ถ้า feature นี้เป็น CRUD ล้วนที่ไม่มี rule ให้เทส นั่นคือการจ่ายเปล่า
ทั้งในผังข้างบนและในบรรทัด _payments.ChargeAsync(...) ของ handler เห็นเรื่องเดียวกัน — ฝั่ง Clean ยังตัดเงินนอก transaction อยู่ดี และอยู่ก่อนบันทึกเหมือนกันเป๊ะกับฝั่ง MVC
การมี IPaymentGateway คั่นไว้ทำให้เทสง่ายขึ้นก็จริง ส่วนเรื่องสลับผู้ให้บริการ อย่าเพิ่งให้เครดิตตรงนี้ — หัวข้อ จะเกิดอะไรถ้าต้องเปลี่ยนของข้างล่าง จะถอนเครดิตนั้นคืนเกือบหมด เพราะฝั่ง MVC ก็มี IStripeClient คั่นอยู่แล้วเหมือนกัน
แต่มันไม่ได้แตะปัญหา dual-write เลยแม้แต่นิดเดียว — ถ้าตัดเงินสำเร็จแล้ว SaveAsync ล้มเหลว เงินก็ยังหายไปโดยไม่มีออเดอร์อยู่ดี
ทางแก้จริงคือ outbox หรือ saga ซึ่งทำได้ทั้งสองสถาปัตยกรรม Clean แค่ทำให้ย้ายไปทำง่ายกว่าเพราะมีจุดเดียวที่ต้องแก้ อย่าปล่อยให้ใครเข้าใจว่าการแยกสี่ชั้นแก้เรื่องนี้ให้แล้ว
สี่สถานการณ์ที่เห็นความต่าง
หัวข้อที่มีชื่อว่า “สี่สถานการณ์ที่เห็นความต่าง”ความต่างโผล่ตอนมี “อย่างที่สอง” เข้ามา ไม่ใช่ตอนมีเส้นทางเดียว
POST /orders เส้นเดียวยังไม่พอจะตัดสินอะไร เพราะทั้งสองฝั่งก็ทำงานถูกต้องเหมือนกัน ความต่างโผล่ตอนมีอย่างที่สองเข้ามาต่างหาก — ทางเข้าที่สอง ผู้บริโภคที่2 side effect ตัวที่สอง สี่หัวข้อข้างล่างคือสี่ครั้งที่ “อย่างที่สอง” เข้ามา
หนึ่ง — cron ต้องทำงานเดียวกันกับ HTTP
หัวข้อที่มีชื่อว่า “หนึ่ง — cron ต้องทำงานเดียวกันกับ HTTP”โจทย์: ลูกค้าตั้งออเดอร์ประจำไว้ ระบบต้องสั่งให้อัตโนมัติทุกสัปดาห์ ตรรกะเดียวกับที่ POST /orders ทำทุกประการ
เริ่มจากข้อเท็จจริงที่มักถูกเล่าผิดก่อน: OrderService เรียกจาก BackgroundService ได้จริง ไม่ต้องแก้อะไรสักบรรทัด แค่ inject เข้าไปแล้วเรียก method เดิม ใครก็ตามที่บอกว่า MVC + Service Layer เอา logic ไปใช้ที่อื่นไม่ได้ กำลังพูดเรื่องที่ไม่จริง ความต่างของสองฝั่งอยู่ที่อื่น
สิ่งที่รอทีมอยู่คือสามด่านที่ไม่มีอะไรเตือนล่วงหน้า:
- ด่านแรก — scope
BackgroundServiceถูกลงทะเบียนเป็น singleton ส่วนOrderServiceขึ้นกับDbContextซึ่งเป็น scoped ลืมเรียกCreateScope()เมื่อไร บนเครื่องนักพัฒนาจะเจอ scope validation ของ Development ด่าให้ตั้งแต่ตอนบูต แต่ถ้าหลุดขึ้น production ที่ resolve จาก root provider มันจะกลายเป็นObjectDisposedExceptionแบบสุ่ม ๆ แทน - ด่านที่สอง — HTTP context ถ้าที่ไหนสักแห่งใน
OrderServiceมี_httpContext.User.GetUserId()อยู่สักบรรทัด เรียกจาก cron แล้วจะได้NullReferenceExceptionตอนตีสาม หรือแย่กว่านั้นคือได้userIdเป็น null แล้วบันทึกลงฐานข้อมูลเงียบ ๆ - ด่านที่สาม — มองไม่เห็น ซึ่งแพงที่สุด คือไม่มีใครในทีมรู้ว่ามันขึ้นกับ HTTP จนกว่ามันจะพัง เพราะ dependency พวกนี้ไม่ได้ประกาศไว้ที่ไหนเลย มันซ่อนอยู่กลาง method
ฝั่ง Clean ไม่ต้องแตะ PlaceOrderHandler เลยสักบรรทัด เพราะ dependency ทุกตัวของมันประกาศไว้ใน constructor ครบตั้งแต่วันแรก อยากรู้ว่ามันต้องการอะไรก็อ่าน constructor จบ
ส่วนเรื่อง “ใครเป็นคนสั่ง” ถูกแยกออกมาเป็น port ชื่อ ICurrentUser ที่มี adapter สองตัว: HttpCurrentUser อ่านจาก claims ของ request ส่วน SystemUser คืน UserId.System ตรง ๆ cron ผูก adapter ตัวหลังแล้วเรียก handler เดิมได้เลย
แต่ให้เครดิตเท่าที่ควรได้เท่านั้น: Clean ปลดล็อกด่านที่สองกับด่านที่สาม ส่วนด่านแรกยังอยู่ครบ
EfOrderRepository ก็ลากเอา FoodOrderingDbContext ที่เป็น scoped เข้ามาอยู่ดี BackgroundService ที่เป็น singleton จึงต้องจัดการ scope เองเหมือนกันเป๊ะทั้งสองฝั่ง
นั่นเป็นเรื่องของ hosting model ของ .NET ไม่ใช่เรื่องของสถาปัตยกรรม และไม่มีการแบ่งชั้นแบบไหนทำให้มันหายไป
ไม่ใช่ “reuse ไม่ได้” แต่เป็น “reuse ได้ แต่ไม่รู้ว่าต้องเตรียมอะไร จนกว่ามันจะพังใน production ตอนตีสาม”
สอง — queue consumer ที่ข้อความมาซ้ำได้
หัวข้อที่มีชื่อว่า “สอง — queue consumer ที่ข้อความมาซ้ำได้”โจทย์ใหม่: ย้ายการสั่งอาหารไปรับผ่านคิว และคิวเกือบทุกเจ้าในโลกรับประกันแบบ at-least-once เท่านั้น แปลว่าข้อความเดิมมาสองครั้งเป็นเรื่องปกติ ไม่ใช่อุบัติเหตุ — ถ้าไม่ทำอะไรเลย ลูกค้าจะได้ออเดอร์ซ้ำและถูกตัดเงินสองรอบ
ทั้งสองฝั่งแก้ปัญหานี้ได้ ASP.NET Core มี middleware และ DI มี decorator ให้ครอบ OrderService ได้จริงโดยไม่ต้องแก้ตัว service เลย
ประโยคที่ถูกต้องจึงไม่ใช่ “ทำไม่ได้” แต่คือ: ฝั่ง MVC ไม่มี seam กลางที่ทุก entry point วิ่งผ่านโดยธรรมชาติ — ต้องประดิษฐ์เอง และคนส่วนใหญ่ไม่ประดิษฐ์
เพราะ controller เรียก service ตัว1 consumer เรียกอีกตัว1 cron เรียก method ที่สาม จุดที่จะแทรกการเช็กซ้ำจึงไม่ได้มีที่เดียว มันมีเท่ากับจำนวนทางเข้า และทุกครั้งที่เพิ่มทางเข้าใหม่ก็ต้องจำให้ได้ว่าต้องแทรกอีกจุด
ฝั่ง Clean มีสองทางที่ถูกทั้งคู่:
- ทางแรก — pipeline behavior ที่ครอบ handler ทุกตัว เช็ก
MessageIdที่เคยเห็นแล้วเป็น decorator ตัวเดียว เขียนครั้งเดียวใช้ได้กับทุก command ที่มีและที่จะมี - ทางที่สอง — ใส่ natural key ลงใน aggregate แล้วผูก unique index ที่ฐานข้อมูล ข้อความซ้ำก็สร้างออเดอร์ซ้ำไม่ได้ตั้งแต่แรก
ต้องพูดให้ตรงว่าทางที่สองไม่ใช่ของ Clean เลยสักนิด ทีม MVC เพิ่ม column เดียวกันกับ index เดียวกันก็ได้คำรับประกันเท่ากันเป๊ะ สิ่งที่ Clean เติมเข้ามาจริงคือทางแรกทางเดียว: seam ตัวเดียวที่ทุกทางเข้าวิ่งผ่านอยู่แล้ว จึงมีที่ให้แขวน decorator ไว้จุดเดียวจบ
รายละเอียดของ idempotent consumer ทั้งชุด รวมถึงหน้าตาของ idempotency key ที่ใช้ได้จริง อยู่ในบทที่ 4 ของคอร์ส Payments & Ledger ซึ่งสอนเรื่องนี้ไว้ครบแล้ว ทั้งบนสถาปัตยกรรมไหนก็ใช้ได้
ระหว่างสองทางนั้น ทางที่สองแข็งแรงกว่าเพราะกันซ้ำได้แม้ระบบ dedupe จะพังเอง — ฐานข้อมูลเป็นคนบังคับ ไม่ใช่ code ของเรา แต่ต้องออกแบบ key ให้ถูกตั้งแต่แรกและแก้ทีหลังยาก
ส่วนทางแรกติดตั้งเร็วกว่ามากแต่พึ่ง storage ภายนอกอีกชั้น ถ้า storage นั้นล่มพร้อมกับคิว retry การกันซ้ำก็หายไปด้วย ในทางปฏิบัติทีมส่วนใหญ่ทำทางแรกก่อน แล้วค่อยเติมทางที่สองเฉพาะตารางที่แพงเกินกว่าจะพลาด
สาม — เพิ่ม side effect หลังสั่งสำเร็จ
หัวข้อที่มีชื่อว่า “สาม — เพิ่ม side effect หลังสั่งสำเร็จ”โจทย์ที่เกิดขึ้นจริงกับทุกระบบ: ตอนแรกออเดอร์สำเร็จแล้วส่งเมล เดือนถัดมาต้องบวกแต้มสะสม ไตรมาสหน้าต้องแจ้ง Slack ของทีมร้าน แล้วก็ต้องยิงเข้า data warehouse
ฝั่ง MVC ทุกครั้งที่มีข้อที่สี่ข้อที่ห้า ต้องกลับไปแก้ PlaceOrderAsync method เดิม ผลตามมาสามอย่าง:
- merge conflict — สองทีมที่เพิ่ม side effect คนละตัวในสปรินต์เดียวกันจะชนกันที่ method เดียวกันเสมอ
- เทสบวม — unit test ของ “สร้างออเดอร์” ต้อง mock ของสี่ตัวที่ไม่เกี่ยวกับการสร้างออเดอร์เลยแม้แต่นิดเดียว
- ความล้มเหลวลามถึงผู้ใช้ — การแจ้ง Slack ที่โยน exception จะทำให้ผู้ใช้เห็น 500 สำหรับออเดอร์ที่บันทึกสำเร็จไปแล้ว
เหตุผลของข้อสุดท้ายคือ _slack จะไปต่อแถวอยู่หลัง SaveChangesAsync เหมือน _email.SendAsync ผลคือออเดอร์นั้นมีอยู่จริงในฐานข้อมูล แต่ลูกค้าไม่มีทางรู้ ทั้งที่การแจ้ง Slack ไม่ใช่เงื่อนไขของการสั่งอาหารเลย
ฝั่ง Clean เพิ่ม side effect คือ เพิ่ม file ไม่ใช่แก้ file เดิม — เขียน class ใหม่1 file ที่ implement INotificationHandler<DomainEventNotification<OrderPlaced>> (นี่คือรูปแบบที่ ddd-in-code บทที่ 6 ต่อสายไว้จริงผ่านสะพาน MediatR ไม่ใช่ IDomainEventHandler<T> ลอย ๆ)
PlaceOrderHandler ไม่ถูกแตะเลยสักบรรทัด เทสเดิมของมันไม่ต้องแก้ และสองทีมที่เพิ่ม side effect คนละตัวก็ไม่แตะ file เดียวกันอีกต่อไป
มีเงื่อนไขข้อเดียวคือเรื่องที่วาง file:
- วางไว้ใน
FoodOrdering.Application— MediatR สแกนเจอเอง เพราะ composition root ลงทะเบียนไว้ว่าRegisterServicesFromAssemblyContaining<PlaceOrderHandler>() - วางไว้ที่
FoodOrdering.Infrastructureแบบที่คอร์สวาง handler ของOrderPlacedไว้ — ต้องเติมบรรทัดสแกน assembly นั้นในProgram.csอีกหนึ่งบรรทัด
ความต่างที่แท้จริงไม่ใช่ว่าใคร commit ก่อน เพราะทั้งสองฝั่งบันทึกเสร็จแล้วค่อยทำ side effect เหมือนกัน ความต่างคือฝั่ง Clean ตัดสินลำดับนั้นไว้ที่ จุดเดียว แล้ว side effect ทุกตัวที่เพิ่มมาทีหลังก็รับลำดับนั้นไปเองโดยไม่ต้องตัดสินใหม่
aggregate เก็บ event ไว้ระหว่างทำงานโดยยิงอะไรออกไปเองไม่ได้ ส่วนบรรทัดที่ตัดสินว่าจะ dispatch ตอนไหนมีอยู่บรรทัดเดียวใน handler (ซึ่งยังสลับขึ้นไปข้างบนได้ถ้าใครตั้งใจสลับ อย่างที่เตือนไว้แล้ว แต่มีให้สลับอยู่จุดเดียว)
ส่วนฝั่ง MVC ลำดับนั้นเป็นเรื่องของการพิมพ์ล้วน ๆ ที่ต้องตัดสินใหม่ทุกครั้งที่เพิ่ม side effect หนึ่งตัว ลองดู code ข้างบนอีกครั้ง: _stripe.ChargeAsync อยู่ ก่อน SaveChangesAsync ส่วน _email.SendAsync อยู่ หลัง ไม่มีอะไรในโครงสร้างบอกว่าอันไหนควรอยู่ตรงไหน คนถัดไปที่ย้ายบรรทัดขึ้นไปข้างบนจึงไม่มีใครทักท้วง
แต่ต้องพูดให้ตรงว่าอะไรที่ยัง ไม่ ได้มาจากตรงนี้: ตราบใดที่ยัง dispatch แบบ in-process อยู่ — ซึ่งคือสิ่งที่บรรทัด _dispatcher.DispatchAsync(...) ใน code ข้างบนทำ — handler ตัวใหม่ที่โยน exception ก็เด้งกลับออกมาทาง Handle แล้วผู้ใช้เห็น 500 เหมือนฝั่ง MVC เป๊ะ ๆ
จะแยกความล้มเหลวออกจากกันได้จริงต้องเปลี่ยนไปเขียน event ลงตารางกลาง (outbox) ใน transaction เดียวกับออเดอร์ แล้วให้ worker คนละตัวไปอ่านและส่งต่อ — ตอนนั้นแหละที่ Slack ล่มแล้วลูกค้ายังสั่งอาหารได้ตามปกติ
ราคาที่จ่ายคือการไล่ code ยากขึ้นจริง ๆ และเรื่องนี้ต้องพูดตรง ๆ
ฝั่ง MVC กด F12 บน _email.SendAsync ก็กระโดดไปที่ code ที่ทำงานจริงทันที และอ่านลำดับทั้งหมดจบในหน้าจอเดียวว่ามีอะไรเกิดขึ้นบ้างหลังสั่งสำเร็จ
ฝั่ง Clean กด F12 บน DispatchAsync แล้วจะไปโผล่ที่ interface ที่ไม่ได้บอกอะไรเลย จะรู้ว่ามีใครฟัง OrderPlaced อยู่บ้างต้อง grep หา handler ทั้งหมดเอาเอง แล้วยังไม่มีอะไรบอกว่ามันทำงานตามลำดับไหน
สำหรับ junior ที่เพิ่งเข้าทีม ความต่างนี้อาจกินเวลาทั้งบ่าย
outbox ที่เพิ่งพูดถึงไม่ได้แก้แค่เรื่อง handler โยน exception มันแก้รอยรั่วอีกอันที่มองไม่เห็นด้วย: ถ้า process ตายหลัง SaveAsync สำเร็จแต่ยัง dispatch ไม่ครบ event ที่เหลือหายไปเลยและไม่มีใครรู้
ทั้งสองอาการรักษาด้วยยาตัวเดียวกันคือ transactional outbox — เขียน event ลงตาราง Outbox ใน transaction เดียวกับ order แล้วมี worker แยกไปอ่านแล้วส่งต่อ พร้อม retry
จุดนี้ฝั่ง Clean ได้เปรียบชัดเจนเพราะ event เป็นวัตถุที่มีตัวตนอยู่แล้ว การเขียนมันลงตารางเพิ่มเป็นงานไม่กี่บรรทัดที่จุดเดียว ส่วนฝั่ง MVC ที่ side effect เป็นการเรียก method ตรง ๆ ต้องประดิษฐ์ทั้งชุดขึ้นมาใหม่ก่อน — ต้องมีสิ่งที่เรียกว่า event ให้ได้ก่อน จึงจะเขียนมันลงตารางได้
สี่ — unit test ของกฎยอดขั้นต่ำ
หัวข้อที่มีชื่อว่า “สี่ — unit test ของกฎยอดขั้นต่ำ”เหลือหัวข้อสุดท้าย และเป็นหัวข้อที่มีน้ำหนักที่สุดของทั้งหน้า โจทย์ง่ายที่สุดในบทความ: พิสูจน์ว่าออเดอร์ยอด 50 บาทของร้านที่ตั้งขั้นต่ำไว้ 200 บาท ถูกปฏิเสธ
ฝั่ง MVC กฎอยู่ใน OrderService ซึ่งขึ้นกับ DbContext แปลว่าต้องมีฐานข้อมูลก่อนถึงจะเทสกฎได้:
// FoodOrdering.Tests/OrderServiceTests.cs — เทสกฎ "ยอดต้องถึงขั้นต่ำ" ฝั่ง MVCvar options = new DbContextOptionsBuilder<FoodOrderingDbContext>() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options;
await using var db = new FoodOrderingDbContext(options);db.Restaurants.Add(new Restaurant { Id = 1, MinimumOrder = 200m });await db.SaveChangesAsync();
var stripe = new Mock<IStripeClient>();var email = new Mock<IEmailSender>();var service = new OrderService(db, stripe.Object, email.Object);
var dto = new CreateOrderDto{ RestaurantId = 1, Lines = new List<CreateOrderLineDto> { new() { UnitPrice = 50m, Qty = 1 } },};
await Assert.ThrowsAsync<InvalidOperationException>(() => service.PlaceOrderAsync(dto));สิบกว่าบรรทัดของ arrange เพื่อ assert หนึ่งบรรทัด และในบรรดา arrange ทั้งหมดนั้นไม่มีบรรทัดไหนเกี่ยวกับ “ยอดต้องถึงขั้นต่ำ” เลยสักบรรทัด — ทั้งหมดคือค่าใช้จ่ายในการพา OrderService ให้ยอมทำงาน
แถมยังมีกับดักซ้อนอยู่: UseInMemoryDatabase ไม่ใช่ SQL จริง มันไม่บังคับ constraint ไม่แปล LINQ เป็น SQL และไม่มี transaction แบบเดียวกับของจริง เทสผ่านเขียวหมดแล้ว production พังยังเกิดขึ้นได้อยู่ดี
ฝั่ง Clean + DDD กฎอยู่ใน Order ซึ่งไม่รู้จักใครเลย เทสจึงเหลือเท่าที่กฎต้องการจริง ๆ:
// FoodOrdering.Domain.Tests/OrderTests.cs — ไม่มี mock ไม่มี DB ไม่มี async// ต่อยอดจาก ddd-in-code บทที่ 6 — เพิ่ม parameter ที่สามสำหรับบทความนี้var lines = new List<OrderLine>{ new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(50)),};
var ex = Assert.Throws<InvalidOperationException>(() => Order.Place(new OrderId(Guid.NewGuid()), lines, Money.Thb(200)));
Assert.Equal("ยอดไม่ถึงขั้นต่ำของร้าน", ex.Message);arrange-act-assert สามก้าว ที่ทุกบรรทัดพูดถึงกฎที่กำลังทดสอบตรง ๆ ไม่มี mock เพราะไม่มีอะไรให้ mock ไม่มี async เพราะไม่มี I/O และเทสตัวนี้รันจบในหลักไมโครวินาที
นี่คือความหมายจริงของประโยค “ย้ายความซับซ้อนไปอยู่ในที่ที่เทสง่ายที่สุด” ในธีสิสตอนต้น
แต่ต้องพูดให้ครบ: การเทส PlaceOrderHandler ยังต้อง mock อยู่ ทั้ง IMenuCatalog, IOrderRepository, IPaymentGateway และ IDomainEventDispatcher ต้องมี test double ครบทุกตัว เพราะ handler มีหน้าที่ประสานกับโลกภายนอกโดยตรง
Clean ไม่ได้ทำให้ทุกเทสง่ายขึ้น — มันย้าย กฎที่สำคัญที่สุด ออกไปอยู่ในที่ที่ไม่มี dependency เท่านั้น ส่วนที่เหลือก็ยังมี mock เท่าเดิม
นี่คือเหตุผลที่แข็งแรงที่สุดของทั้งหน้า ถ้า domain ของคุณมีกฎเยอะ ข้อนี้ข้อเดียวคุ้มค่าใช้จ่ายทั้งหมดที่พูดมา แต่ถ้า domain ของคุณคือ CRUD — ไม่มีกฎให้เทส และเหตุผลนี้ก็หายไปพร้อมกัน
จะเกิดอะไรถ้าต้องเปลี่ยนของข้างล่าง
หัวข้อที่มีชื่อว่า “จะเกิดอะไรถ้าต้องเปลี่ยนของข้างล่าง”ข้อได้เปรียบข้อนี้มีจริง แต่เล็กกว่าที่มันฟังดูมาก — และของที่คนชอบยกมาคู่กันแทบไม่ใช่ข้อได้เปรียบเลย
ประโยคขายที่ได้ยินบ่อยที่สุดของ Clean คือ “เปลี่ยนฐานข้อมูลได้โดยไม่กระทบ business logic” ประโยคนี้ไม่ได้ผิด แต่เล็กกว่าที่มันฟังดูมาก และของอีกอย่างที่คนชอบยกมาคู่กัน — การเปลี่ยนผู้ให้บริการชำระเงิน — แทบไม่ใช่ข้อได้เปรียบเลย
หัวข้อนี้จึงลองเปลี่ยนทั้งสองอย่างจริง ๆ แล้วนับว่าต้องเปิด file ไหนบ้าง
เปลี่ยน A — จาก EF Core เป็น Dapper
หัวข้อที่มีชื่อว่า “เปลี่ยน A — จาก EF Core เป็น Dapper”| MVC + Service Layer | Clean + DDD | |
|---|---|---|
| LINQ ที่ต้องเขียนใหม่อยู่ที่ไหน | ทุก method ของทุก service ที่แตะ _db | ชั้น Infrastructure เท่านั้น — ในหน้านี้คือ EfOrderRepository |
| ชั้นที่ไม่ต้องแตะเลยสักบรรทัด | ไม่มี เพราะกฎกับ query อยู่ใน method เดียวกัน | Domain กับ Application |
| เทสที่หน้านี้เขียนไว้ | OrderServiceTests ต้องรื้อ — บรรทัด arrange ของมันสร้าง FoodOrderingDbContext ขึ้นมาเอง | OrderTests ไม่ขยับสักตัวอักษร เพราะไม่เคยรู้จัก EF ตั้งแต่แรก |
| เงื่อนไขที่ทำให้ช่องขวาเป็นจริง | — | IOrderRepository ต้องไม่คืน IQueryable ถ้าคืน ผลลัพธ์จะกลับไปเท่ากับช่องซ้ายทันที |
แถวสุดท้ายไม่ใช่ข้อยกเว้นเล็ก ๆ ที่ต่อท้ายไว้ให้ครบ มันคือเงื่อนไขทั้งหมดของตารางนี้
repository ที่คืน IQueryable แปลว่า LINQ ยังกระจายอยู่ในชั้น Application เหมือนเดิม เปลี่ยนแค่ที่อยู่ของมัน และนี่คือกับดักที่พบบ่อยพอจะได้ที่นั่งแถวหน้าในหัวข้อกับดักข้างล่าง
“แก้ file เดียว” ไม่ได้แปลว่า “แก้ง่าย” และตรงนี้ตัวเลขกลับทิศอย่างน่าสนใจ
ฝั่ง MVC มี Order แบบ get/set ล้วน ซึ่งเป็นรูปร่างที่ Dapper หยิบไปเติมค่าได้แทบไม่ต้องบอกอะไร งานจึงกระจายแต่ตื้น
ส่วนฝั่ง Clean มี Order ที่เก็บบรรทัดไว้ใน field ปิด และมี Total เป็น Money ที่นอนอยู่2 column — Dapper ไม่มีทั้ง backing field mapping และ owned type ให้ใช้ คนเขียนต้อง multi-map แล้วประกอบ aggregate กลับขึ้นมาเองทั้งก้อน งานจึงกระจุกแต่ลึก
model ที่ปิดสนิทซึ่งเป็นเหตุผลที่เลือก DDD ตั้งแต่ต้น ก็เป็นเหตุผลเดียวกับที่การย้าย ORM ไม่ใช่งานสิบนาที
เปลี่ยน B — จาก Stripe เป็น Omise
หัวข้อที่มีชื่อว่า “เปลี่ยน B — จาก Stripe เป็น Omise”หัวข้อนี้คือที่ที่บทความเปรียบเทียบส่วนใหญ่พูดเกินจริง เพราะทั้งสองฝั่งมี interface คั่นอยู่แล้วตั้งแต่ตารางตัวละครในหัวข้อแรก
| MVC + Service Layer | Clean + DDD | |
|---|---|---|
| มี interface คั่นอยู่แล้วไหม | มี — IStripeClient | มี — IPaymentGateway |
| งานหลักที่ต้องทำ | เขียน adapter ตัวใหม่ แล้วสลับบรรทัดลงทะเบียนใน DI | เหมือนกันทุกประการ |
| ใครเป็นคนกำหนดรูปร่างของ interface | ผู้ให้บริการ — ชื่อ IStripeClient ผูกกับเจ้าเดียวตั้งแต่ตัวอักษรแรก และยอดที่ _stripe.ChargeAsync(dto.CustomerId, total) ส่งเข้าไปเป็นตัวเลขเปล่าที่ไม่มีสกุลเงินติดมาด้วย | domain — _payments.ChargeAsync(order.Total, ct) ชื่อ port ไม่เอ่ยถึงเจ้าไหน และยอดเป็น Money ที่พกสกุลเงินไว้ในชนิดข้อมูล |
| ผลตอนเปลี่ยนเจ้าจริง | ต้องเปลี่ยนชื่อ interface แล้วไล่แก้ทุกจุดที่เอ่ยชื่อนั้น ส่วน call site จะขยับด้วยหรือไม่ ขึ้นกับว่า dto.CustomerId เป็น id ของ app เราเองหรือของฝั่งผู้ให้บริการ ซึ่งหน้านี้ไม่ได้บอก | ชื่อไม่ต้องเปลี่ยน แต่ที่ call site ไม่ขยับก็เพราะ port ตัวนี้ไม่รับ identity ของลูกค้าเข้ามาเลย ถ้าเจ้าใหม่ต้องการ ก็ต้องเพิ่มเข้าไปและขยับเหมือนกัน |
ใน project จริงช่องซ้ายมักหนักกว่านี้อีก เพราะทีมมักลอกหน่วยของ SDK มาด้วยเลย — long amountInSatang แทนที่จะเป็น decimal — พอเปลี่ยนไปหาเจ้าที่คิดหน่วยไม่เหมือนกัน ทุกจุดที่คูณร้อยเอาไว้ก็ต้องไล่แก้ตาม
สรุปของตาราง B จึงเป็น แทบเสมอ ไม่ใช่ชัยชนะของ Clean และต้องพูดให้ตรงกว่านั้นอีกว่าสิ่งที่ปกป้อง call site ไม่ใช่เส้นแบ่ง project แต่เป็น คำที่ใช้ตั้งชื่อ interface และ parameter
ฝั่ง MVC เปลี่ยน IStripeClient เป็นชื่อกลาง ๆ แล้วเปลี่ยน parameter ให้พูดภาษา domain ได้ตั้งแต่บ่ายนี้ โดยไม่ต้องแยก project สัก project
และในทางกลับกัน ถ้าสปรินต์หน้ามีคนเติม string stripeCustomerId เข้าไปใน IPaymentGateway ข้อได้เปรียบนี้ก็หายไปทันที ไม่มีการแบ่งชั้นแบบไหนกันไว้ได้
ทีมส่วนใหญ่ไม่เคยเปลี่ยน database เลยตลอดอายุ project ถ้านี่คือเหตุผลเดียวที่คุณจะย้ายไป Clean — เหตุผลไม่พอ ผลตอบแทนจริงอยู่ที่การเทสและการเพิ่ม feature โดยไม่แก้ของเดิม ซึ่งเกิดขึ้นทุกสัปดาห์ ไม่ใช่ทุก 5 ปี
ต้นทุนจริงของ Clean
หัวข้อที่มีชื่อว่า “ต้นทุนจริงของ Clean”ต้นทุนที่แพงที่สุดไม่ใช่จำนวน file ที่ทุกคนเห็นตั้งแต่วันแรก แต่คือค่าเจรจากับ ORM ที่ไม่มีใครพูดถึงตอนขาย
หัวข้อที่แล้วชั่งดูว่า Clean พอจะได้เปรียบอยู่บ้างตรงไหน หัวข้อนี้เป็นด้านที่มันจ่าย และต้องพูดให้ยาวพอกัน เพราะต้นทุนที่แพงที่สุดไม่ได้อยู่ที่จำนวน file ซึ่งทุกคนเห็นตั้งแต่วันแรกอยู่แล้ว แต่อยู่ที่ค่าเจรจากับ ORM ที่ไม่มีใครพูดถึงตอนขาย
model แบบ DDD กับ ORM มีเป้าหมายที่สวนทางกันโดยธรรมชาติ
- domain อยากปิดให้สนิท — ไม่มี setter สาธารณะ ไม่มี collection ที่ใครก็เติมได้ สร้างได้ทางเดียวคือผ่าน factory ที่ตรวจกฎ
- ORM มีงานเดียว — เขียนค่าจากฐานข้อมูลกลับเข้าไปข้างในให้ได้
ทุกจุดที่สองอย่างนี้ชนกันต้องมีคนเขียน configuration มาไกล่เกลี่ย ตัวอย่างที่ใกล้ตัวที่สุดคือ Money ใน code ข้างบน — มันมี2 field EF Core จึงต้องถูกสั่งให้พับมันลงเป็น2 column ในตาราง Orders ด้วย OwnsOne ไม่งั้นมันจะพยายามให้ตารางกับ primary key แก่สิ่งที่ไม่ควรมี identity
และ file configuration พวกนี้ไม่มีอยู่ในฝั่ง MVC เลยสัก file เพราะ Order แบบ get/set ล้วนที่เก็บยอดเป็น decimal คือรูปร่างที่ EF Core map ได้เองโดยไม่ต้องบอกอะไร คอร์ส EF Core & Persistence มีทั้งคอร์สที่ว่าด้วยการไกล่เกลี่ยชุดนี้
ห้าจุดข้างล่างคือที่ที่สองฝ่ายชนกันบ่อยที่สุด และบทที่รับผิดชอบแต่ละจุดอยู่ column ขวา:
| สิ่งที่ domain อยากได้ | ราคาที่ EF Core เก็บ | อ่านต่อ |
|---|---|---|
Lines เปิดออกแค่ IReadOnlyCollection ไม่มีใครเติมบรรทัดจากข้างนอกได้ | EF เขียนผ่าน property นั้นไม่ได้ ต้องให้มันอ่านและเขียนที่ field ตรง ๆ แทน — convention จับคู่ Lines กับ _lines ให้เองได้ก็จริง แต่คอร์สแนะนำให้ระบุ HasField("_lines") กับ UsePropertyAccessMode(PropertyAccessMode.Field) ชัด ๆ เพื่อไม่ให้ mapping พังเงียบ ๆ วันที่มีคนเปลี่ยนชื่อ field | บทที่ 3 |
Money เป็น value object ไม่ใช่ decimal เปลือย | พับลงเป็น column ด้วย OwnsOne กติกาคือ value object ค่าเดียวใช้ value converter ส่วนตัวที่มีหลาย field ต้องใช้ owned type | บทที่ 3 |
OrderId เป็นชนิดของตัวเอง ไม่ใช่ Guid เปลือย | convention เริ่มต้นเดาว่ามันเป็น entity ที่ต้องมี key ของตัวเอง แล้วล้มตั้งแต่ตอนสร้าง model ต้องมี HasConversion ให้ครบทุกชนิด ID และเติม ValueComparer เมื่อ reference equality เอาไม่อยู่ | บทที่ 2 |
| โหลด aggregate ทั้งก้อนเพื่อให้ invariant ยังคุมได้ | Include ทำให้ข้อมูลของ Order ซ้ำทุกแถวลูก และซ้อนหลาย collection พร้อมกันจะบานเป็นผลคูณ ต้องเลือกเองว่าจะรับ single query ไว้หรือแตกเป็น AsSplitQuery() | บทที่ 7 |
Order.Place(...) เป็นทางสร้างทางเดียว constructor เป็น private | EF ไม่เรียก Place() มันจับคู่ชื่อ parameter ที่เป็น scalar เข้ากับ constructor แล้วเรียกตัวนั้นตอน materialize กฎที่เขียนไว้ใน Place() จึงไม่มีผลตอนโหลดกลับ หน้าที่กันข้อมูลเสียตั้งแต่ตอนเขียนยังเป็นของเราเสมอ | Clean บทที่ 2 · EF บทที่ 3 |
แถวสุดท้ายมีผลลึกกว่าที่ตารางบอก — เพราะ EF เข้าทาง constructor ไม่ใช่ทาง Place() กฎที่เขียนไว้ใน Place() จึงถูกบังคับเฉพาะ ตอนเขียน เท่านั้น
ถ้าแถวในฐานข้อมูลเสียอยู่ก่อนแล้ว ไม่ว่าจะจาก script แก้มือ จาก migration เก่า หรือจากยุคก่อนที่กฎข้อนี้จะมี EF ก็จะปั้น Order ที่ละเมิดกฎกลับขึ้นมาให้เงียบ ๆ
นี่คือวงเล็บที่ค้างไว้ตอนพูดถึง Order.Place ในหัวข้อก่อนหน้า — คำรับประกันของ factory ครอบเฉพาะออเดอร์ที่ code สร้างขึ้นใหม่ ไม่ได้ครอบข้อมูลที่โหลดกลับมา และไม่มีการแบ่งชั้นแบบไหนเปลี่ยนข้อนี้ได้
- junior เข้าทีมช้าลง — ก่อนจะแก้ code บรรทัดแรกได้ ต้องเข้าใจก่อนว่าทำไมของสี่กองถึงอยู่คนละ project และลูกศรระหว่างกองชี้ไปทางไหน
- mapping สามชั้น — request DTO เข้ามาเป็นรูป1 domain เป็นอีกรูป column ในฐานข้อมูลเป็นรูปที่สาม สะพานฝั่ง DTO ต้องเขียนใหม่ทุก feature แม้ field จะเหมือนกันเป๊ะ ส่วนสะพานฝั่ง persistence เขียนครั้งเดียวต่อ entity แล้วต้องตามแก้ทุกครั้งที่ domain เปลี่ยนรูป
- “file นี้ควรวางตรงไหน” — คำถามนี้กินเวลาประชุมจริงและมีผลจริง อย่างที่เห็นในสถานการณ์ที่สาม: handler ตัวเดียวกันวางใน
FoodOrdering.Applicationแล้ว MediatR สแกนเจอเอง แต่วางในFoodOrdering.Infrastructureแล้วต้องเติมบรรทัดในProgram.cs - debug ยากขึ้น — กด F12 แล้วไปโผล่ที่ interface คือราคาที่จ่ายทุกวัน ไม่ใช่ทุกไตรมาส
- PR ใหญ่ขึ้น — feature เดียวแตะ4 project diff ที่ reviewer ต้องอ่านจึงยาวกว่าเดิม ทั้งที่เนื้อการเปลี่ยนแปลงเท่ากัน
ไม่มีข้อไหนในกล่องนี้เป็นเหตุผลให้ไม่ใช้ Clean แต่ทุกข้อเป็นเหตุผลให้ไม่ใช้ Clean กับ module ที่ไม่ได้อะไรกลับมา
ห้ากับดักที่เจอบ่อย
หัวข้อที่มีชื่อว่า “ห้ากับดักที่เจอบ่อย”ห้ารูปแบบที่ทำให้ทีมจ่ายค่าผ่านทางครบทุกบาทแล้วไม่ได้อะไรกลับมา
ห้าข้อข้างล่างไม่ใช่ความผิดพลาดของคนที่ไม่รู้ แต่เป็นรูปแบบที่ทำให้ทีมจ่ายค่าผ่านทางครบทุกบาทแล้วไม่ได้อะไรกลับมา
| # | กับดัก | อาการ | ทำไมถึงผิด | ทางแก้ |
|---|---|---|---|---|
| 1 | repository คืน IQueryable | _orders.Query().Where(...) โผล่ในชั้น Application | abstraction รั่ว — ชั้นในยังผูกกับ EF อยู่ดี เทสต้องมี query provider และวันที่เปลี่ยน ORM ก็ยังต้องไล่แก้ทั้งชั้น | คืน IReadOnlyList หรือรับ specification object เข้าไปแทน |
| 2 | folder ครบสี่ชั้น แต่ entity ยัง anemic | มี project Domain แต่ข้างในมีแต่ property | ได้ ceremony ครบโดยไม่ได้ประโยชน์สักข้อ — กฎยังอยู่ใน service เหมือนเดิม แต่ตอนนี้ต้องเปิด4 file แทน2 file | ย้ายกฎเข้า entity ให้จบ หรือยอมรับตรง ๆ ว่า module นี้ไม่ต้องใช้ DDD |
| 3 | repository หนึ่งตัวต่อหนึ่งตาราง | IOrderLineRepository, IAddressRepository | ได้ DAL ที่เปลี่ยนชื่อ ไม่ใช่ repository — OrderLine ถูกแก้ได้โดยไม่ผ่าน Order invariant จึงคุมไม่ได้อีกต่อไป | 1 repository ต่อ1 aggregate root เท่านั้น |
| 4 | ย้ายทั้ง codebase รวดเดียว | branch ค้างสามเดือน merge ไม่ลง | module CRUD ล้วนไม่ได้ประโยชน์อะไรเลย แต่กินเวลาย้ายพอ ๆ กับ module ที่ได้ | ย้ายเฉพาะ module ที่มีกฎ ที่เหลือปล่อยเป็น MVC ได้ตลอดกาล |
| 5 | DTO ของ API รั่วเข้า domain | Order มี [JsonPropertyName] หรือ [Required] ติดอยู่ | รูปร่างของ JSON กลายเป็นตัวกำหนดรูปร่างของ domain เปลี่ยน API เมื่อไรคือเปลี่ยน domain เมื่อนั้น | map ที่ขอบเสมอ แม้ field จะเหมือนกันทุกตัว |
ในห้าข้อนี้ข้อที่สองคือข้อที่เจอบ่อยที่สุด และเจ็บที่สุดด้วย เพราะมันจ่ายค่าผ่านทางทั้งหมดที่กล่องต้นทุนข้างบนบรรยายไว้ แล้วไม่ได้เหตุผลข้อที่แข็งแรงที่สุด — การเทสกฎโดยไม่มี DB — กลับมาเลยสักนิดเดียว
ส่วนทางแก้ของข้อ 1 มีราคาที่ต้องพูดให้ครบ ไม่งั้นมันจะฟังดูไร้เดียงสา
พอ repository คืน IReadOnlyList แล้ว หน้าจอที่อยากรู้แค่ว่า “ออเดอร์ใบนี้มีกี่รายการ” ก็จะลาก Order ทั้ง aggregate ขึ้นมาทั้งก้อน หรือไม่อย่างนั้น repository ก็จะบวมด้วย method เฉพาะกิจอย่าง GetOrdersByRestaurantWithLinesPagedAsync
ทางออกจริงไม่ใช่การหาลายเซ็นที่ฉลาดกว่านี้ แต่คือการยอมรับว่า เส้นทางอ่านกับเส้นทางเขียนไม่ใช่ปัญหาเดียวกัน — ฝั่งเขียนต้องผ่าน aggregate เสมอเพราะมี invariant ให้รักษา ส่วนฝั่งอ่านไม่มี invariant อะไรให้รักษาตั้งแต่แรก
บทที่ 7 ของคอร์ส EF Core & Persistence เดินเรื่องนี้ไว้ครบตั้งแต่ N+1 จนถึงประตูของ CQRS
พอแยกสองฝั่งออกจากกันแล้ว ฝั่งอ่านก็ยิง projection ตรงเข้า DTO แบน ๆ ได้เลย ไม่ต้องผ่าน aggregate ไม่ต้องเข้า change tracking และให้ฐานข้อมูลคำนวณ COUNT ให้แทนที่จะโหลดบรรทัดทั้งหมดขึ้นมานับเอง
นั่นคือ CQRS version เบาที่สุดที่ให้ประโยชน์แล้วจริง โดยยังใช้ FoodOrderingDbContext ก้อนเดียวและตารางชุดเดิมอยู่ ไม่ต้องแยกฐานข้อมูลและไม่ต้องมี event sourcing
หน้านี้เทียบสองทางเพราะสองทางนี้คือสิ่งที่คนถามถึง ไม่ใช่เพราะมีอยู่แค่สองทาง
การจัด code ตาม feature (Vertical Slice) ให้ testability ส่วนใหญ่โดยไม่ต้องมี4 project และอยู่ปนกับ Clean ในโซลูชันเดียวกันได้
ที่นี่ไม่อธิบายซ้ำเพราะบทที่ 8 ของคอร์ส Clean Architecture ด้วย .NET สอนไว้ครบแล้วทั้งโครง folder และเกณฑ์ตัดสินว่าเมื่อไรควรเลือกอันไหน
แล้ว project ของฉันควรอยู่ตรงไหน
หัวข้อที่มีชื่อว่า “แล้ว project ของฉันควรอยู่ตรงไหน”ตอบทีละ module ไม่ใช่ตอบให้ทั้งระบบทีเดียว — ค่าเฉลี่ยของทั้งระบบไม่ตรงกับ module ไหนเลยสัก module
หกคำถามข้างล่างคือทุกอย่างในหน้านี้ย่อลงเป็นแบบประเมิน ตอบตามความจริงของ code ที่คุณดูแลอยู่ ไม่ใช่ตามที่อยากให้มันเป็น
และตอบ ทีละ module ไม่ใช่ตอบให้ทั้งระบบทีเดียว — ระบบเดียวมีทั้ง module ที่คู่ควรกับ Clean และ module ที่ย้ายไปแล้วเสียเปล่าอยู่ด้วยกันเสมอ ถ้าตอบให้ทั้งระบบ สิ่งที่ได้คือค่าเฉลี่ยที่ไม่ตรงกับ module ไหนเลยสัก module
project ของฉันควรอยู่ตรงไหน
0 จาก 12 คะแนนอยู่กับ MVC + Service Layer ต่อไป
โดเมนนี้ไม่มีกฎมากพอจะคุ้มค่าผ่านทาง การย้ายคือการจ่ายเปล่า คะแนนนี้ตอบให้ทั้งระบบไม่ได้ ให้ตอบทีละ module — ระบบเดียวมีทั้ง module ที่คู่ควรและไม่คู่ควรพร้อมกันเสมอ
คำตัดสินมีสามแบบ แบบแรกคือ “อยู่กับ MVC + Service Layer ต่อไป” — domain ไม่มีกฎมากพอจะคุ้มค่าผ่านทาง การย้ายคือการจ่ายเปล่า
อีกสองแบบไม่ได้ตอบว่าทำหรือไม่ทำ แต่ตอบว่าให้เดินบันไดในโหมด ย้ายทีละขั้น ไปถึงขั้นไหน — แบบหนึ่งให้หยุดกลางทาง บวกขั้นสุดท้ายที่ราคาถูกและกันถอยหลัง แล้วเก็บประโยชน์ราว 70% ที่ต้นทุนราว 30% อีกแบบให้ไปจนสุดเฉพาะ module ที่ทำคะแนนสูง
และทั้งสามแบบปิดท้ายด้วยประโยคเดียวกันว่าคะแนนนี้ตอบให้ทั้งระบบไม่ได้
บันไดที่คำตัดสินสองแบบหลังอ้างถึงคือบันไดในโหมด ย้ายทีละขั้น ซึ่งบอกไว้ทุกขั้นว่าหยุดค้างตรงนั้นได้หรือไม่ และถ้าต้องถอยจะถอยอย่างไร การหยุดกลางทางจึงเป็นผลลัพธ์ที่ตั้งใจไว้แต่แรก ไม่ใช่งานที่ทำไม่เสร็จ
ย้ายทีละขั้นแบบ Strangler Fig
หัวข้อที่มีชื่อว่า “ย้ายทีละขั้นแบบ Strangler Fig”เขียนให้คนที่ตัดสินใจไปแล้วว่าจะย้าย คำถามที่เหลือคือย้ายอย่างไรโดยไม่ต้องหยุดส่งงานสามเดือน
คำตอบที่ใช้ได้จริงมีอยู่แบบเดียวคือ Strangler Fig — ปลูกของใหม่ขึ้นรอบ ๆ ของเดิมทีละกิ่ง ให้ทั้งสองอย่างทำงานคู่กันไป แล้วค่อย ๆ ย้ายภาระจากต้นเดิมไปต้นใหม่ ไม่ใช่โค่นต้นเดิมทิ้งแล้วปลูกใหม่
รูปแบบดั้งเดิมของมันอยู่ที่หน้า Strangler Fig ซึ่งใช้ façade เป็นตัวดักและกระจายคำขอ ส่วนบันไดข้างล่างนี้ไม่มี façade แยกต่างหาก — OrdersController ตัวเดิมทำหน้าที่จุดกระจายนั้นเอง
ผลที่ตามมาซึ่งต้องยอมรับตั้งแต่ก่อนเริ่มคือ codebase จะอยู่ในสภาพครึ่งเก่าครึ่งใหม่เป็นเดือน ๆ จะมีช่วงที่เปิดโซลูชันขึ้นมาแล้วเห็น OrderService ตัวเดิมกับ handler ตัวใหม่นอนอยู่ข้าง ๆ กัน
คนที่เพิ่งเข้าทีมจะถามว่า “ตกลงต้องเขียนแบบไหน” และคำตอบที่ซื่อสัตย์คือ “แบบใหม่ ส่วนแบบเก่ายังไม่ได้ย้าย”
นั่นไม่ใช่ความล้มเหลวของแผน มันคือรูปร่างของแผนตอนที่มันกำลังทำงาน แผนที่ไม่ต้องผ่านสภาพนี้เลยมีอยู่แบบเดียวคือ rewrite ซึ่งเป็นกับดักที่โหมด เทียบสองทาง เตือนไว้ในชื่อ “ย้ายทั้ง codebase รวดเดียว”
ย้ายทีละขั้นแบบ Strangler Fig
9 ขั้นรายการขั้นบันไดทั้งหมด
ขั้น 4 — แตก OrderService ตัวอ้วนเป็น handler ทีละตัว
PlaceOrderHandler, CancelOrderHandler, … ทีละตัว ไม่ใช่พร้อมกัน ระหว่างนี้ OrderService เดิมยังอยู่ ทำหน้าที่ที่เหลือต่อไป
ถอยกลับ: ชี้ controller กลับไปเรียก OrderService ตัวเดิม
สามสีในภาพนั้น — ซึ่งบันไดกางออกมาให้ดูอยู่ขั้นเดียว — อ่านแบบนี้:
- เทา — ยังเป็นของเดิม ยังไม่ได้ย้ายมาเดินเส้นทางใหม่
- เขียว — ย้ายแล้ว เดินบนเส้นทางใหม่เต็มตัว
- เหลือง — adapter หรือรอยต่อชั่วคราวที่ตั้งใจจะทิ้งทีหลัง ไม่ใช่ปลายทาง
สีเป็นสัญญาณที่สอง ไม่ใช่สัญญาณเดียว — ป้ายกำกับในแต่ละเลนพูดเรื่องเดียวกันเป็นตัวหนังสืออยู่แล้ว จึงอ่านออกแม้แยกสีไม่ได้
และชื่อสีเป็นชื่อคร่าว ๆ เท่านั้น เพราะทั้งสามถูกปรับค่าให้คอนทราสต์ผ่านทั้งสองธีม เหลืองบนธีมสว่างจึงออกไปทางน้ำตาล ส่วนเขียวบนธีมมืดออกไปทางเขียวอมฟ้า
บันไดนี้หยุดค้างได้ไม่เท่ากันทุกขั้น
หัวข้อที่มีชื่อว่า “บันไดนี้หยุดค้างได้ไม่เท่ากันทุกขั้น”ป้าย หยุดค้างได้ / หยุดค้างแล้วเจ็บ ที่ติดอยู่ทุกขั้นไม่ได้มีไว้ประดับ ขั้นต้น ๆ หยุดค้างได้จริงทุกขั้น และทางถอยของมันก็เบามาก — หนักที่สุดคือ revert commit เดียว หรือชี้ controller กลับไปเรียก OrderService ตัวเดิม
แต่ตรงกลางบันไดมีสามขั้นติดกันที่ติดป้ายว่าหยุดค้างแล้วเจ็บ:
- ย้าย rule เข้า entity
- ใส่ value object แทน primitive
- ใช้
OrderPlacedแทนการเรียก side effect ตรง ๆ
สามขั้นนี้ต้องวางแผนเป็นก้อนเดียว ไม่ใช่สามรายการในแบ็กล็อกที่ใครหยิบไปทำเมื่อไรก็ได้ — และวางแผนเป็นก้อนเดียวไม่ได้แปลว่า deploy เป็นก้อนเดียว ขั้นที่เสี่ยงที่สุดในสามขั้นนี้บอกไว้เองว่าให้แยก deploy ออกจากขั้นอื่น
แล้วบันไดจึงกลับมาหยุดค้างได้อีกครั้งที่ขั้นสุดท้าย ซึ่งเสี่ยงต่ำและถอยด้วยการลบ file เทสทิ้ง นั่นคือเหตุผลที่แบบประเมินข้างบนแนะให้ “หยุดกลางทาง บวกขั้นสุดท้าย” ได้โดยไม่ขัดกับบันได
และทางถอยของสามขั้นตรงกลางก็ไม่ใช่ทางถอยชนิดเดียวกับขั้นต้น ๆ มันเป็นตาข่ายที่ต้อง เตรียมไว้ล่วงหน้า ไม่ใช่ปุ่มที่กดตอนเกิดเรื่อง:
- ย้าย rule เข้า entity — ระบุทางถอยของตัวเองไว้ตรง ๆ ว่ายาก และต้องพึ่ง characterization test เป็นตาข่ายรอง ซึ่งก็คือผลผลิตของขั้นแรก ๆ ที่ยังไม่แตะ code สักบรรทัด
- ใส่ value object — ต้องมี migration ย้อนกลับพร้อมอยู่ก่อน deploy
- ใช้
OrderPlaced— ต้องมี feature flag ที่สลับกลับไปเรียก side effect แบบ inline ได้
ทั้งสามอย่างนี้ถ้าไม่ได้เตรียมไว้ก่อน วันที่ต้องใช้ก็จะไม่มีให้ใช้ ขั้นที่ดูเหมือนเสียเวลาที่สุดของบันได — ขั้นที่นั่งเขียนเทสบันทึกพฤติกรรมเดิมโดยไม่ได้แก้อะไรเลยสักบรรทัด — จึงกลายเป็นตาข่ายที่ขั้นถอยกลับยากที่สุดยกมาอ้างเป็นทางถอยของตัวเอง
ในบรรดาขั้นทั้งหมด ขั้นที่บันไดบอกเองว่าให้ผลตอบแทนต่อความพยายามสูงสุดคือขั้นที่แทรก IOrderRepository คั่นระหว่าง service กับ FoodOrderingDbContext ทั้งที่มันยังคืน entity ตัวเดิมและยังไม่มี domain model ใหม่อะไรเลย
เหตุผลคือมันคือลูกศรกลับทิศเส้นแรกในสามเส้นที่หัวข้อตัวละครของโหมด เทียบสองทาง บอกให้จำ — พอมีเส้นนี้แล้ว เส้นที่เหลือก็ทำตามแบบเดียวกันได้โดยไม่ต้องอธิบายหลักการใหม่ให้ใครฟังอีก
สภาพครึ่งทางที่ diagram นิ่งไม่เคยแสดง
หัวข้อที่มีชื่อว่า “สภาพครึ่งทางที่ diagram นิ่งไม่เคยแสดง”ผังสถาปัตยกรรมเกือบทุกใบที่เคยเห็นวาดอยู่สองสภาพเท่านั้นคือ “ก่อน” กับ “หลัง” ส่วนสภาพที่ทีมใช้ชีวิตอยู่จริงนานที่สุดคือสภาพระหว่างทาง ซึ่งไม่มีใครวาด บันไดข้างบนจึงเปิดค้างไว้ที่ขั้นที่กางภาพนั้นออกมาให้ดู
ภาพนั้นคือ request สองเส้นที่วิ่งอยู่พร้อมกันใน app เดียว
POST /orders— ย้ายไปเดินเส้นใหม่ผ่านPlaceOrderHandlerแล้วPOST /orders/{id}/cancel— ยังวิ่งเส้นเดิมผ่านOrderServiceอยู่
ทั้งคู่ share FoodOrderingDbContext ก้อนเดียวกันผ่านรอยต่อที่ตั้งใจให้เป็นของชั่วคราว สภาพนี้ไม่ได้แปลว่ามีใครทำงานค้าง มันคือสิ่งที่คำว่า “ทีละตัว ไม่ใช่พร้อมกัน” หน้าตาเป็นจริง ๆ
รอยต่อตรงนั้นคือจุดที่ต้องระวังที่สุดของทั้งช่วง เพราะสองเส้นทางที่ share context ก้อนเดียวกันแปลว่า share mapping ชุดเดียวกันไปด้วย
วันที่ขั้นใส่ value object ลงมา Total ที่เคยเป็น decimal column เดียวจะกลายเป็น owned type ที่พับลง2 column ตามที่หัวข้อต้นทุนของโหมด เทียบสองทาง อธิบายไว้ — และเส้นเก่าที่ยังอ่านเขียนของเดิมอยู่ก็อ่านเขียนผ่าน context ก้อนเดียวกันนั้น การเปลี่ยนรูปข้อมูลจึงลงพร้อมกันทั้งสองเส้นเสมอ เลือกให้ลงเฉพาะเส้นใหม่ไม่ได้
นี่คือเหตุผลที่จับต้องได้ว่าทำไมขั้นนั้นถึงติดป้ายว่าหยุดค้างแล้วเจ็บ และทำไมทางถอยของมันถึงเป็น migration ย้อนกลับที่ต้องเตรียมก่อน deploy ไม่ใช่การ revert commit เดียวแบบขั้นต้น ๆ
อีกข้อที่มักเข้าใจผิดคือเรื่องสีเหลือง — มันไม่ได้หายไปเพราะมีคนเดินไปลบมันทิ้งในสปรินต์ไหนสักสปรินต์ มันหายไปตอนที่เส้นเทาเส้นสุดท้ายกลายเป็นเขียว
ตัว FoodOrderingDbContext ยังอยู่ที่เดิมและยังทำงานเหมือนเดิม สิ่งที่หายไปคือสถานะ “เป็นรอยต่อระหว่างของเก่ากับของใหม่” ของมันเท่านั้น
ตราบใดที่ยังเหลือเส้นเทาอยู่แม้เส้นเดียว รอยต่อนั้นก็ยังเป็นของที่ต้องคอยดูแล และการที่มันอยู่ยาวเป็นเดือนคือเรื่องปกติ ไม่ใช่สัญญาณว่าทีมช้า
หยุดที่ขั้นที่บันไดทำเครื่องหมายไว้ — ขั้นสุดท้ายก่อนช่วงที่หยุดค้างแล้วเจ็บ — ได้ประโยชน์ไปแล้วราว 70% และต้องพูดให้ตรงว่าเป็น 70% แบบไหน:
- เทส use case ได้โดยไม่ต้องมีฐานข้อมูลจริง (ยังต้องมี fake อยู่ ไม่ใช่0 mock)
- ความรับผิดชอบแยกเป็นคนละ file คนละชื่อจริง ๆ
- LINQ ที่เคยกระจายอยู่ในทุก method ของ service ถูกต้อนไปอยู่หลัง interface — ข้อหลังนี้จริงเฉพาะเมื่อ repository ไม่คืน
IQueryableออกมา ซึ่งเป็นกับดักข้อแรกที่โหมด เทียบสองทาง เตือนไว้
ส่วนเหตุผลที่แข็งแรงที่สุดของทั้งหน้า คือการเทสกฎธุรกิจโดยไม่มี mock สักตัว อยู่เลยจุดนั้นไป เพราะมันเกิดขึ้นตอนกฎย้ายเข้า Order ซึ่งอยู่ในช่วงที่หยุดค้างแล้วเจ็บพอดี
ถ้า domain ของคุณมีกฎเยอะพอ ช่วงนั้นคือช่วงที่คุ้ม แต่ถ้าเป็น CRUD ล้วน กฎที่จะย้ายเข้า entity ก็แทบไม่มีตั้งแต่แรก ไม่ต้องไปถึง และนั่นไม่ใช่ความล้มเหลว มันคือการอ่านคะแนนของตัวเองออก
คำศัพท์ในหน้านี้
หัวข้อที่มีชื่อว่า “คำศัพท์ในหน้านี้”เก็บเฉพาะคำที่หน้านี้เดินเรื่องด้วยจริง ไม่ใช่รายการศัพท์ DDD ทั้งกอง
มีสามคำที่หน้านี้ไม่ได้เอ่ยชื่อไว้เลยสักครั้ง — Unit of Work, Application Service และ Bounded Context — ที่ยกมาไว้ด้วยเพราะเป็นคำที่คนอ่านมักคาดว่าจะเจอ และคำตอบว่าทำไมถึงไม่เจอมีค่าพอ ๆ กับคำนิยามของมันเอง column ขวาจึงเป็นได้ทั้งจุดที่คำนั้นโผล่ในหน้านี้ และเหตุผลที่มันไม่โผล่
ส่วนจุดที่อ้างถึงบางจุดอยู่ในโหมดหรือระดับผู้อ่านที่ไม่ได้เปิดอยู่ตอนนี้ ถ้าอยากตามไปดูให้สลับสวิตช์ด้านบนก่อน
| คำ | ความหมาย | ที่มันโผล่ในหน้านี้ |
|---|---|---|
| Aggregate Root | entity ที่เป็นประตูเดียวเข้าถึงกลุ่มของมัน และเป็นผู้รับประกันว่ากฎของทั้งกลุ่มยังจริงอยู่เสมอ | Order ที่ถือ OrderLine ทุกบรรทัดไว้ข้างใน — constructor เป็น private ส่วน Order.Place(...) คือ constructor ทางเดียวที่เปิดให้ใช้ (ออเดอร์ที่ ORM โหลดกลับขึ้นมาไม่ได้ผ่านทางนี้) |
| Value Object | ชนิดข้อมูลที่ตัดสินความเท่ากันด้วย ค่า ไม่ใช่ด้วย identity และพก guard ของตัวเองมาด้วย | Money ที่เก็บสกุลเงินไว้ในชนิดข้อมูล จนกฎ “เทียบเงินคนละสกุลไม่ได้” มีที่อยู่ใน code แทนที่จะอยู่ในความจำของนักพัฒนา |
| Domain Event | บันทึกว่า “เกิดอะไรขึ้นแล้ว” ใน domain เป็นอดีตเสมอ ไม่ใช่คำสั่งให้ใครทำอะไร | OrderPlaced ที่ Order.Place เก็บเข้า collection ภายใน แล้วถูกกระจายหลังบันทึกสำเร็จ |
| Invariant | กฎที่ต้องเป็นจริงเสมอตลอดอายุของ aggregate ไม่ใช่เช็กแค่ตอนสร้าง | ”ยอดรวมต้องถึงยอดขั้นต่ำของร้าน” กับ “ตะกร้าว่างสร้างออเดอร์ไม่ได้” — แต่หน้านี้บังคับทั้งสองข้อได้แค่ตอน Order.Place สร้างของใหม่ ส่วนแถวที่ ORM โหลดกลับขึ้นมาไม่ได้ผ่านทางนั้น ระยะห่างระหว่างคำว่า “เสมอ” ใน column กลางกับสิ่งที่ code บังคับได้จริง คือหัวข้อต้นทุนของโหมด เทียบสองทาง |
| Unit of Work | ขอบเขตที่รวบการเปลี่ยนแปลงหลายอย่างไว้ commit หรือ rollback พร้อมกันครั้งเดียว | หน้านี้ไม่มี IUnitOfWork เลยสักตัว เพราะ FoodOrderingDbContext เป็น unit of work อยู่แล้ว — SaveChangesAsync หนึ่งครั้งคือ1 transaction |
| Port / Adapter | port คือ interface ที่วงในประกาศว่าตัวเองต้องการอะไร ส่วน adapter คือของจริงวงนอกที่มา implement | IPaymentGateway เป็น port ส่วน StripePaymentGateway เป็น adapter — ลูกศรชี้จาก adapter เข้าหา port |
| Anemic model | entity ที่มีแต่ property ไม่มีพฤติกรรม กฎธุรกิจจึงไปกองอยู่ใน service ข้างนอกแทน | Order ฝั่ง MVC ที่เป็น get/set ล้วน ส่วนกฎอยู่ใน OrderService — ตัวมันเองไม่ใช่กับดัก แต่การแยกครบสี่ชั้นแล้ว entity ยัง anemic คือกับดักที่โหมด เทียบสองทาง บอกว่าเจ็บที่สุด |
| Bounded Context | ขอบเขตที่คำหนึ่งคำมีความหมายเดียว และ model หนึ่งชุดใช้ได้ทั้งขอบเขต | หน้านี้อยู่ใน context เดียวตลอด — Order ทุก block code หมายถึงสิ่งเดียวกัน ส่วน “module” ที่แบบประเมินสั่งให้ให้คะแนนทีละอัน คือหน่วยที่มักโตขึ้นเป็น context ในภายหลัง |
| Service Layer | ชั้นที่ประกาศชุดปฏิบัติการที่ app ทำได้ แล้วประสานงานให้แต่ละอันจนจบ (Fowler, PoEAA) | OrderService ฝั่งซ้ายของหน้านี้ ที่ถือ FoodOrderingDbContext ไว้ในมือโดยตรง |
| Application Service | คนประสานงานของ use case หนึ่งอัน ไม่มีกฎธุรกิจเป็นของตัวเองสักข้อ | PlaceOrderHandler คือ application service ตัวหนึ่ง แม้หน้านี้จะเรียกมันว่า handler ตลอด — มันดึงของจาก port แล้วเรียก Order.Place โดยไม่ตัดสินกฎเอง |
| Repository | abstraction ที่ทำตัวเหมือน collection ของ aggregate ในหน่วยความจำ หนึ่งตัวต่อ1 aggregate root | IOrderRepository ที่มี SaveAsync(order, ct) — ส่วนการทำหนึ่งตัวต่อหนึ่ง ตาราง คือกับดักที่โหมด เทียบสองทาง เรียกว่า DAL ที่เปลี่ยนชื่อ |
| Outbox | เขียน event ลงตารางใน transaction เดียวกับ state แล้วให้ worker คนละตัวไปอ่านและส่งต่อพร้อม retry | ทางแก้ที่หน้านี้ยกมาทุกครั้งที่พูดถึงความล้มเหลว หลัง บันทึกสำเร็จ เช่นตอน handler โยน exception แล้วผู้ใช้เห็น 500 |
| Idempotency | เรียกซ้ำกี่ครั้งผลก็เท่าเดิม ไม่มี side effect เพิ่มจากครั้งที่สอง | คิวแบบ at-least-once ส่งข้อความเดิมมาสองครั้ง แล้วต้องไม่เกิดออเดอร์ซ้ำและไม่ตัดเงินสองรอบ |
| Dual-write | เขียนสองที่ที่ไม่ได้อยู่ใน transaction เดียวกัน แล้วอย่างใดอย่างหนึ่งล้มหรือ process ตายคั่นกลาง | ตัดเงินสำเร็จแล้ว SaveAsync ล้ม เงินหายโดยไม่มีออเดอร์ — เกิดได้เหมือนกันทั้งสองสถาปัตยกรรม |
| Strangler Fig | ย้ายทีละส่วนโดยให้ของเก่ากับของใหม่ทำงานคู่กันไป แทนการรื้อทั้งระบบทีเดียว | บันไดทั้งอันในโหมด ย้ายทีละขั้น รวมถึงภาพสองเส้นทางที่วิ่งพร้อมกัน |
| Characterization test | เทสที่บันทึกพฤติกรรม ปัจจุบัน ไว้เป็น expected ก่อนแก้อะไร ไม่ใช่พฤติกรรมที่ควรจะเป็น | ขั้นแรก ๆ ของบันไดที่ยังไม่แตะ code สักบรรทัด และเป็นตาข่ายรองที่ขั้นถอยกลับยากอ้างถึง |
| Specification | ห่อ “เงื่อนไขคัดเลือก” ที่ตอบใช่/ไม่ใช่ ไว้เป็นอ็อบเจ็กต์ที่เรียกซ้ำได้ทั้งตอน guard และตอน query | หนึ่งในสองทางแก้ที่โหมด เทียบสองทาง เสนอไว้แทนการให้ repository คืน IQueryable ออกมา |
| CQRS | แยก model ฝั่งอ่านออกจากฝั่งเขียน เพราะฝั่งอ่านไม่มี invariant ให้รักษาตั้งแต่แรก | version เบาที่สุดคือยิง projection ตรงเข้า DTO แบน ๆ โดยยังใช้ FoodOrderingDbContext ก้อนเดียวและตารางชุดเดิม |
อ่านต่อ
หัวข้อที่มีชื่อว่า “อ่านต่อ”ที่มาของสิ่งที่หน้านี้ยืมมา แล้วก็ที่ที่ควรไปต่อ
ส่วนจุดที่อ้างถึงบางจุดอยู่ในโหมดหรือระดับผู้อ่านที่ไม่ได้เปิดอยู่ตอนนี้ ถ้าอยากตามไปดูให้สลับสวิตช์ด้านบนก่อน
แหล่งภายนอก
หัวข้อที่มีชื่อว่า “แหล่งภายนอก”- Martin Fowler — สามหน้าที่หน้านี้ยืมคำมาใช้โดยตรง
- Service Layer ในแค็ตตาล็อกของ Patterns of Enterprise Application Architecture คือนิยามต้นทางของคำที่หน้านี้ใช้เป็นชื่อฝั่งซ้ายทั้งหน้า
- Anemic Domain Model คือที่มาของคำว่า anemic ในตารางตัวละคร
- Strangler Fig Application คือที่มาของบันไดในโหมดย้ายทีละขั้น
- Eric Evans — Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) หนังสือที่นิยาม aggregate, aggregate root, value object และ repository ไว้เป็นครั้งแรก ไม่ใส่ link เพราะเป็นหนังสือ ไม่ใช่หน้าเว็บ
- Robert C. Martin — The Clean Architecture (The Clean Code Blog, 2012) ต้นทางของกฎทิศทาง dependency ที่หน้านี้เรียกสั้น ๆ ว่า “ลูกศรกลับทิศ” — ไซต์นี้มีบทแปลไทยฉบับเต็ม
- Jimmy Bogard — A better domain events pattern (2014) คือที่มาของรูปแบบที่ aggregate เก็บ event ไว้ในตัวเองก่อน แล้วให้คนอื่นมากระจายทีหลัง ซึ่งเป็นรูปแบบเดียวกับที่
Order.PlaceกับPlaceOrderHandlerในหน้านี้ใช้ - Kamil Grzybek — The Outbox Pattern (2019) คำอธิบาย transactional outbox ฝั่ง .NET ที่ลงรายละเอียดถึงระดับตารางและ worker — คือทางแก้ที่หน้านี้อ้างถึงหลายครั้งแต่ไม่ได้กางให้ดู
ไปต่อที่ไหนในไซต์นี้
หัวข้อที่มีชื่อว่า “ไปต่อที่ไหนในไซต์นี้”แบบประเมินข้างบนให้คำตัดสินมาแล้ว ตารางนี้แปลคำตัดสินนั้นเป็นที่ที่ควรไปต่อ — อ่านแถวที่ตรงกับผลของคุณแถวเดียวก็พอ ไม่ต้องไล่ทั้งตาราง
| ถ้าผลของคุณคือ | ไปที่นี่ | เจออะไร |
|---|---|---|
| อยู่กับ MVC + Service Layer ต่อไป | Model-View-Controller | ทวนว่า MVC แยกอะไรออกจากอะไรกันแน่ เพื่อให้ที่ที่คุณอยู่เป็นที่ที่ตั้งใจอยู่ ไม่ใช่ที่ที่เผลออยู่ |
| ย้าย แล้วอยากได้โครงสี่ชั้นกับลูกศรกลับทิศก่อนเป็นอย่างแรก | Clean Architecture ด้วย .NET | 8 บทเรียน สร้าง ASP.NET Core API 4 project บน domain Order เดียวกับหน้านี้ |
| ย้ายให้สุด — กฎต้องเข้าไปอยู่ใน entity จริง | DDD ใน code จริง | value object, aggregate, invariant, domain event, specification บน Order ตัวเดิม |
| เจอค่าเจรจากับ ORM ตามที่หัวข้อต้นทุนบรรยายไว้ | EF Core & Persistence | owned type, strongly-typed id, transaction boundary และ query ที่ไม่ N+1 |
| อยากได้เหตุผลที่แข็งแรงที่สุดของหน้านี้เป็นทักษะจริง | ทดสอบ app DDD/Clean Architecture ให้เป็น | เทส domain แบบไม่ต้อง mock, test double, Testcontainers และ e2e ผ่าน API |
| ติดเรื่อง idempotency, outbox หรือเงินหายเพราะ dual-write | ออกแบบระบบเงินและ Ledger ด้วย DDD | idempotent transfer, double-entry ledger, escrow, refund และการ reconcile |
| อยากได้กฎทิศทาง dependency จากปากต้นทาง | The Clean Architecture | บทแปลไทยฉบับเต็มของ Robert C. Martin |
| อยากได้ port/adapter จากปากต้นทาง | Hexagonal Architecture (Ports and Adapters) | บทแปลไทยฉบับเต็มของ Alistair Cockburn |