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

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 ของ​คุณ​มี​กฎ​มาก​พอ​จะ​คุ้ม​ค่า​ผ่าน​ทาง​ไหม”

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 แล้ว​ส่ง​ผลลัพธ์​กลับ
OrderServicebusiness rule อยู่​ที่​นี่​ทั้งหมด และ​ขึ้น​กับ FoodOrderingDbContext โดยตรง
Orderanemic — property แบบ get/set ล้วน ไม่มี method ที่​บังคับ​กฎ​อะไร​เลย
FoodOrderingDbContextEF Core — OrderService เรียก​ใช้​ตรง ๆ ไม่มี​อะไร​คั่น​กลาง
IStripeClient / IEmailSenderinterface ของ​บริการ​ภายนอก ทีม​ประกาศ​ไว้​เอง​เพราะ​ไม่​งั้น​เทส​ไม่​ได้
SQL Serverฐาน​ข้อมูล​จริง

แถว​รอง​สุดท้าย​คือ​แถว​ที่​มัก​ถูก​เล่า​ผิด ทีม MVC ที่​ทำงาน​เป็น​ย่อม​มี interface คั่น​บริการ​ภายนอก​อยู่​แล้ว

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

ความ​ต่าง​ที่แท้​จริง​ระหว่าง​สอง​ฝั่ง​จึง​ไม่ใช่ “มี interface หรือ​ไม่มี” แต่​คือ ฝั่ง MVC ไม่มี interface คั่น persistenceOrderService ถือ FoodOrderingDbContext ไว้​ใน​มือ​โดยตรง เขียน LINQ ได้​ทุก​บรรทัด​ที่​อยาก​เขียน และ​นั่น​คือ​จุด​ที่​ผลลัพธ์​จะ​ต่าง​กัน​ตลอด​ทั้ง​หน้า​นี้

ฝั่ง Clean + DDD มี​ของ​เดิม​ทั้งหมด​นั้น แต่​กระจาย​อยู่​ใน4 project และ​เพิ่ม​ชื่อ​ใหม่​เข้า​มา​อีก​ชุด:

ชั้นตัว​ละครบทบาท
PresentationOrdersController · SubscriptionCronJob : BackgroundService · OrderQueueConsumerสาม​ทาง​เข้า วิ่ง​เข้าหา​จุด​เดียวกัน​ทั้งหมด
ApplicationPlaceOrderCommand · PlaceOrderHandlerรับ command แล้ว​ประสาน​งาน ไม่มี rule ของ​ตัวเอง
Application (port)IOrderRepository · IMenuCatalog · IPaymentGateway · IDomainEventDispatcher · ICurrentUserinterface ที่​วงใน​ประกาศ​ว่า “ฉัน​ต้องการ​ของ​แบบ​นี้” ส่วน​ใหญ่​ยาว​ไม่​กี่​บรรทัด
DomainOrder (aggregate root) · OrderLine · Money · Quantity · OrderPlacedกฎ​ธุรกิจ​อยู่​ที่​นี่ ไม่รู้จัก EF Core หรือ ASP.NET เลย​สัก​บรรทัด
InfrastructureEfOrderRepository · StaticMenuCatalog · StripePaymentGateway · SmtpEmailSender · FoodOrderingDbContextของ​จริง​ที่​ต่อ​กับ​โลก​ภายนอก และ​เป็น​ที่​เดียว​ที่​มี SQL

ใน​ตาราง​นี้​มี​สาม​เส้น​ที่​ต้อง​เห็น​ให้​ชัด​ที่สุด:

  • EfOrderRepository → IOrderRepository
  • StripePaymentGateway → IPaymentGateway
  • StaticMenuCatalog → IMenuCatalog

ทั้ง3 class อยู่​ชั้น Infrastructure ซึ่ง​เป็น​วงนอก​สุด แต่​ลูกศร​ของ​มัน (คือ using และ ProjectReference ที่ compiler เห็น) ชี้ เข้าหา interface ที่​ชั้น Application เป็น​ผู้​ประกาศ ไม่ใช่​กลับ​กัน

นี่​คือ​จุด​กลับ​ทิศ — และ​เป็น​เหตุผล​เดียว​ที่ PlaceOrderHandler เทส​ได้​โดย​ไม่มี​ฐาน​ข้อมูล ถ้า​คุณ​จำ​อะไร​จาก​หน้า​นี้​ได้​อย่าง​เดียว ให้​จำ​สาม​เส้น​นี้

เส้นทาง​เดียวกัน สอง​รูปร่าง — ฝั่ง​หนึ่ง​อยู่​ใน method เดียว อีก​ฝั่ง​ย้าย​กฎ​เข้าไป​อยู่​ใน Order

POST /orders — เดินทีละ hop

ขั้นที่ 1 จาก 13
MVC + Service Layer · 10 hop
  1. Web
    OrdersController.Create(CreateOrderDto)
  2. Web
    validate ด้วย ModelState
  3. Service
    _orderService.PlaceOrderAsync(dto)
  4. Service
    _db.Restaurants.FindAsync(dto.RestaurantId)service ยิง EF Core ตรง ไม่มีอะไรคั่น
  5. Service
    รวมราคาจากที่ client ส่งมาราคามาจาก request — ไม่มีใครยืนยันกับเมนูจริง
  6. Service
    if (total < restaurant.MinimumOrder) throwกฎอยู่ใน service ไม่ได้อยู่ใน entity
  7. Service
    new Order { Status = "Placed", Total = total }ไม่มีอะไรกันไม่ให้สร้าง Order ที่ผิดกฎ
  8. Service
    _stripe.ChargeAsync(...)ตัดเงินก่อนบันทึก — dual-write
  9. Service
    _db.Orders.Add(order) → SaveChangesAsync()บันทึกสำเร็จ
  10. Service
    _email.SendAsync(...)อยู่ใน request path — SMTP ล่ม = ผู้ใช้เห็น 500 ทั้งที่ order สำเร็จแล้ว
Clean + DDD · 13 hop
  1. Web
    OrdersController.Create(PlaceOrderRequest)
  2. Web
    map → PlaceOrderCommandDTO ของ API ไม่รั่วเข้า domain
  3. Application
    PlaceOrderHandler.Handle
  4. Application
    _menu.GetItemsAsync(...) ผ่าน IMenuCatalogราคามาจากเมนูจริง ไม่ใช่จากที่ client ส่งมา
  5. Infrastructure
    StaticMenuCatalog implement IMenuCatalog⭯ กลับทิศลูกศรกลับทิศ — code อยู่วงนอก แต่ชี้เข้าหา interface ที่วงในประกาศ
  6. Domain
    Order.Place(new OrderId(Guid.NewGuid()), items, minimumOrder)invariant อยู่ใน aggregate → InvalidOperationException
  7. Domain
    aggregate เก็บ OrderPlaced เข้า collectionยังไม่ publish
  8. Application
    _payments.ChargeAsync(order.Total) ผ่าน IPaymentGatewayตัดเงินก่อนบันทึกเหมือนฝั่ง MVC — port ไม่ได้แก้ dual-write ให้
  9. Application
    _orders.SaveAsync(order, ct)บันทึกสำเร็จ
  10. Infrastructure
    EfOrderRepository implement IOrderRepository⭯ กลับทิศลูกศรกลับทิศเส้นที่สอง
  11. Infrastructure
    FoodOrderingDbContext → SQLจุดเดียวในระบบที่มี SQL — และเกิดตอนบันทึก ไม่ใช่ตอนดึงเมนู
  12. Application
    หลังบันทึกสำเร็จ → dispatch OrderPlacedลำดับสำคัญ ห้าม dispatch ก่อนบันทึก
  13. Infrastructure
    OrderPlacedHandler → ส่งเมล หรือเข้า outbox
MVC — Web: OrdersController.Create(CreateOrderDto) · Clean — Web: OrdersController.Create(PlaceOrderRequest)
เส้นทางของ POST /orders สองฝั่ง — ฝั่งซ้าย 10 hop ฝั่งขวา 13 hop ป้าย ⭯ กลับทิศ คือจุดที่ code อยู่วงนอกแต่ลูกศรชี้เข้าหาวงใน ซึ่งเป็นทั้งหมดของคำว่า Clean

code ข้าง​ล่าง​คือ​เนื้อ​ใน​ของ​ขั้น​ที่​สำคัญ​ที่สุด​ใน​ผัง​ข้าง​บน

ฝั่ง 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 ก้อน​เดียว​นี้​ทำงาน​ได้​จริง​และ​ส่ง​งาน​ได้​เร็ว แต่​มี​สาม​อย่าง​ซ่อน​อยู่:

  • ราคา​เชื่อ clientdto.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 อย่างเดียว ไม่มี rule
public 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 ตัว​ที่​สอง สี่​หัวข้อ​ข้าง​ล่าง​คือ​สี่​ครั้ง​ที่ “อย่าง​ที่​สอง” เข้า​มา

โจทย์: ลูกค้า​ตั้ง​ออเดอร์​ประจำ​ไว้ ระบบ​ต้อง​สั่ง​ให้​อัตโนมัติ​ทุก​สัปดาห์ ตรรกะ​เดียว​กับ​ที่ 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 ตอน​ตี​สาม”

โจทย์​ใหม่: ย้าย​การ​สั่ง​อาหาร​ไป​รับ​ผ่าน​คิว และ​คิว​เกือบ​ทุก​เจ้า​ใน​โลก​รับประกัน​แบบ 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 การ​กัน​ซ้ำ​ก็​หาย​ไป​ด้วย ใน​ทาง​ปฏิบัติ​ทีม​ส่วน​ใหญ่​ทำ​ทาง​แรก​ก่อน แล้ว​ค่อย​เติม​ทาง​ที่​สอง​เฉพาะ​ตาราง​ที่​แพง​เกิน​กว่า​จะ​พลาด

โจทย์​ที่​เกิด​ขึ้น​จริง​กับ​ทุก​ระบบ: ตอน​แรกออเดอร์​สำเร็จ​แล้ว​ส่ง​เมล เดือน​ถัด​มา​ต้อง​บวก​แต้ม​สะสม ไตรมาส​หน้า​ต้อง​แจ้ง 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 ล่ม​แล้ว​ลูกค้า​ยัง​สั่ง​อาหาร​ได้​ตาม​ปกติ

จุด​ที่ MVC ชนะ

ราคา​ที่​จ่าย​คือ​การ​ไล่ 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 ให้​ได้​ก่อน จึง​จะ​เขียน​มัน​ลง​ตาราง​ได้

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

ฝั่ง MVC กฎ​อยู่​ใน OrderService ซึ่ง​ขึ้น​กับ DbContext แปล​ว่า​ต้อง​มี​ฐาน​ข้อมูล​ก่อน​ถึง​จะ​เท​สกฎได้:

// FoodOrdering.Tests/OrderServiceTests.cs — เทสกฎ "ยอดต้องถึงขั้นต่ำ" ฝั่ง MVC
var 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 ไหน​บ้าง

MVC + Service LayerClean + 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 ไม่ใช่​งาน​สิบ​นาที

หัวข้อ​นี้​คือ​ที่​ที่​บทความ​เปรียบเทียบ​ส่วน​ใหญ่​พูด​เกิน​จริง เพราะ​ทั้ง​สอง​ฝั่ง​มี interface คั่น​อยู่​แล้ว​ตั้งแต่​ตาราง​ตัว​ละครใน​หัวข้อ​แรก

MVC + Service LayerClean + 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 ปี

ต้นทุน​ที่​แพง​ที่สุด​ไม่ใช่​จำนวน 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 เป็น privateEF ไม่​เรียก 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 ที่​ไม่​ได้​อะไร​กลับ​มา

ห้า​รูปแบบ​ที่​ทำให้​ทีม​จ่าย​ค่า​ผ่าน​ทาง​ครบ​ทุก​บาท​แล้ว​ไม่​ได้​อะไร​กลับ​มา

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

#กับดักอาการทำไม​ถึง​ผิดทาง​แก้
1repository คืน IQueryable_orders.Query().Where(...) โผล่​ใน​ชั้น Applicationabstraction รั่ว — ชั้น​ใน​ยัง​ผูก​กับ EF อยู่ดี เทส​ต้อง​มี query provider และ​วัน​ที่​เปลี่ยน ORM ก็​ยัง​ต้อง​ไล่​แก้​ทั้ง​ชั้นคืน IReadOnlyList หรือ​รับ specification object เข้าไป​แทน
2folder ครบ​สี่​ชั้น แต่ entity ยัง anemicมี project Domain แต่​ข้าง​ใน​มี​แต่ propertyได้ ceremony ครบ​โดย​ไม่​ได้​ประโยชน์​สัก​ข้อ — กฎ​ยัง​อยู่​ใน service เหมือน​เดิม แต่​ตอน​นี้​ต้อง​เปิด4 file แทน2 fileย้าย​กฎ​เข้า entity ให้​จบ หรือ​ยอมรับ​ตรง ๆ ว่า module นี้​ไม่​ต้อง​ใช้ DDD
3repository หนึ่ง​ตัว​ต่อ​หนึ่ง​ตารางIOrderLineRepository, IAddressRepositoryได้ DAL ที่​เปลี่ยน​ชื่อ ไม่ใช่ repository — OrderLine ถูก​แก้​ได้​โดย​ไม่​ผ่าน Order invariant จึง​คุม​ไม่​ได้​อีก​ต่อ​ไป1 repository ต่อ1 aggregate root เท่านั้น
4ย้าย​ทั้ง codebase รวด​เดียวbranch ค้าง​สาม​เดือน merge ไม่​ลงmodule CRUD ล้วน​ไม่​ได้​ประโยชน์​อะไร​เลย แต่​กิน​เวลา​ย้าย​พอ ๆ กับ module ที่​ได้ย้าย​เฉพาะ module ที่​มี​กฎ ที่​เหลือ​ปล่อย​เป็น MVC ได้​ตลอด​กาล
5DTO ของ API รั่ว​เข้า domainOrder มี [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 และ​เกณฑ์​ตัดสิน​ว่า​เมื่อไร​ควร​เลือก​อัน​ไหน

ตอบ​ที​ละ module ไม่ใช่​ตอบ​ให้​ทั้ง​ระบบ​ที​เดียว — ค่า​เฉลี่ย​ของ​ทั้ง​ระบบ​ไม่​ตรง​กับ module ไหน​เลย​สัก module

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

และ​ตอบ ทีละ module ไม่ใช่​ตอบ​ให้​ทั้ง​ระบบ​ที​เดียว — ระบบ​เดียว​มี​ทั้ง module ที่​คู่ควร​กับ Clean และ module ที่​ย้าย​ไป​แล้ว​เสียเปล่า​อยู่​ด้วย​กัน​เสมอ ถ้า​ตอบ​ให้​ทั้ง​ระบบ สิ่ง​ที่​ได้​คือ​ค่า​เฉลี่ย​ที่​ไม่​ตรง​กับ module ไหน​เลย​สัก module

project ของฉันควรอยู่ตรงไหน

0 จาก 12 คะแนน
domain นี้มีกฎที่ถ้าผิดแล้วเสียเงินหรือเสียความน่าเชื่อถือ กี่ข้อ
มีทางเข้าหา logic เดียวกันกี่ทาง
หลัง action หลักสำเร็จ มี side effect กี่ตัว และเพิ่มบ่อยไหม
ระบบนี้จะยังมีคนดูแลอีกกี่ปี
มีกี่คนแตะ code ส่วนนี้พร้อมกัน
requirement ของกฎเปลี่ยนบ่อยแค่ไหน
อยู่กับ MVC + Service Layer ต่อไป

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

แบบประเมิน 6 ข้อสำหรับตัดสินว่า module หนึ่ง ๆ คุ้มกับค่าผ่านทางของ Clean + DDD หรือไม่

คำ​ตัดสิน​มี​สาม​แบบ แบบ​แรก​คือ “อยู่​กับ MVC + Service Layer ต่อ​ไป” — domain ไม่มี​กฎ​มาก​พอ​จะ​คุ้ม​ค่า​ผ่าน​ทาง การ​ย้าย​คือ​การ​จ่าย​เปล่า

อีก​สอง​แบบ​ไม่​ได้​ตอบ​ว่า​ทำ​หรือ​ไม่​ทำ แต่​ตอบ​ว่า​ให้​เดิน​บันได​ใน​โหมด ย้าย​ที​ละ​ขั้น ไป​ถึง​ขั้น​ไหน — แบบ​หนึ่ง​ให้​หยุด​กลาง​ทาง บวก​ขั้น​สุดท้าย​ที่​ราคา​ถูก​และ​กัน​ถอย​หลัง แล้ว​เก็บ​ประโยชน์​ราว 70% ที่​ต้นทุน​ราว 30% อีก​แบบ​ให้​ไป​จน​สุด​เฉพาะ module ที่​ทำ​คะแนน​สูง

และ​ทั้ง​สาม​แบบ​ปิด​ท้าย​ด้วย​ประโยค​เดียวกัน​ว่า​คะแนน​นี้​ตอบ​ให้​ทั้ง​ระบบ​ไม่​ได้

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

เขียน​ให้​คน​ที่​ตัดสิน​ใจ​ไป​แล้ว​ว่า​จะ​ย้าย คำถาม​ที่​เหลือ​คือ​ย้าย​อย่างไร​โดย​ไม่​ต้อง​หยุด​ส่ง​งาน​สาม​เดือน

คำ​ตอบ​ที่​ใช้ได้​จริง​มี​อยู่​แบบ​เดียว​คือ 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 ตัวเดิม

POST /orders → PlaceOrderHandler (ย้ายแล้ว)
POST /orders/{id}/cancel → OrderService (ยังเป็นของเดิม)
ทั้งคู่ share FoodOrderingDbContext เดียวกัน — seam ชั่วคราว
เลือกขั้น 4: แตก OrderService ตัวอ้วนเป็น handler ทีละตัว — เสี่ยงกลาง, หยุดค้างได้
บันไดย้ายจาก MVC + Service ไป Clean + DDD — หยุดที่ขั้น 4 ได้ประโยชน์ไปแล้วราว 70% ขั้นที่เหลือคุ้มก็ต่อเมื่อ domain มีกฎเยอะพอ

สาม​สี​ใน​ภาพ​นั้น — ซึ่ง​บันได​กาง​ออก​มา​ให้​ดู​อยู่​ขั้น​เดียว — อ่าน​แบบ​นี้:

  • เทา — ยัง​เป็น​ของ​เดิม ยัง​ไม่​ได้​ย้าย​มา​เดิน​เส้นทาง​ใหม่
  • เขียว — ย้าย​แล้ว เดิน​บน​เส้นทาง​ใหม่​เต็ม​ตัว
  • เหลือง — 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 ใหม่​อะไร​เลย

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

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

ภาพ​นั้น​คือ 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 Rootentity ที่​เป็น​ประตู​เดียว​เข้าถึง​กลุ่ม​ของ​มัน และ​เป็น​ผู้รับประกัน​ว่า​กฎ​ของ​ทั้ง​กลุ่ม​ยัง​จริง​อยู่​เสมอ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 / Adapterport คือ interface ที่​วงใน​ประกาศ​ว่า​ตัวเอง​ต้องการ​อะไร ส่วน adapter คือ​ของ​จริง​วงนอก​ที่มา implementIPaymentGateway เป็น port ส่วน StripePaymentGateway เป็น adapter — ลูกศร​ชี้​จาก adapter เข้าหา port
Anemic modelentity ที่​มี​แต่ 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 โดย​ไม่​ตัดสิน​กฎ​เอง
Repositoryabstraction ที่​ทำตัว​เหมือน collection ของ aggregate ใน​หน่วย​ความ​จำ หนึ่ง​ตัว​ต่อ1 aggregate rootIOrderRepository ที่​มี 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 EvansDomain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) หนังสือ​ที่​นิยาม aggregate, aggregate root, value object และ repository ไว้​เป็น​ครั้ง​แรก ไม่​ใส่ link เพราะ​เป็น​หนังสือ ไม่ใช่​หน้า​เว็บ
  • Robert C. MartinThe Clean Architecture (The Clean Code Blog, 2012) ต้นทาง​ของ​กฎ​ทิศทาง dependency ที่​หน้า​นี้​เรียก​สั้น ๆ ว่า “ลูกศร​กลับ​ทิศ” — ไซต์​นี้​มีบท​แปล​ไทย​ฉบับ​เต็ม
  • Jimmy BogardA better domain events pattern (2014) คือ​ที่มา​ของ​รูปแบบ​ที่ aggregate เก็บ event ไว้​ใน​ตัวเอง​ก่อน แล้ว​ให้​คน​อื่น​มากระจาย​ทีหลัง ซึ่ง​เป็น​รูปแบบ​เดียว​กับ​ที่ Order.Place กับ PlaceOrderHandler ใน​หน้า​นี้​ใช้
  • Kamil GrzybekThe Outbox Pattern (2019) คำ​อธิบาย transactional outbox ฝั่ง .NET ที่​ลง​รายละเอียด​ถึง​ระดับ​ตาราง​และ worker — คือ​ทาง​แก้​ที่​หน้า​นี้​อ้าง​ถึง​หลาย​ครั้ง​แต่​ไม่​ได้​กาง​ให้​ดู

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

ถ้า​ผล​ของ​คุณ​คือไป​ที่​นี่เจอ​อะไร
อยู่​กับ MVC + Service Layer ต่อ​ไปModel-View-Controllerทวน​ว่า MVC แยก​อะไร​ออก​จาก​อะไร​กัน​แน่ เพื่อ​ให้​ที่​ที่​คุณ​อยู่​เป็น​ที่​ที่​ตั้งใจ​อยู่ ไม่ใช่​ที่​ที่​เผลอ​อยู่
ย้าย แล้ว​อยาก​ได้​โครง​สี่​ชั้น​กับ​ลูกศร​กลับ​ทิศ​ก่อน​เป็น​อย่าง​แรกClean Architecture ด้วย .NET8 บทเรียน สร้าง ASP.NET Core API 4 project บน domain Order เดียว​กับ​หน้า​นี้
ย้าย​ให้​สุด — กฎ​ต้อง​เข้าไป​อยู่​ใน entity จริงDDD ใน code จริงvalue object, aggregate, invariant, domain event, specification บน Order ตัว​เดิม
เจอ​ค่า​เจรจา​กับ ORM ตาม​ที่​หัวข้อ​ต้นทุน​บรรยาย​ไว้EF Core & Persistenceowned 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 ด้วย DDDidempotent transfer, double-entry ledger, escrow, refund และ​การ reconcile
อยาก​ได้​กฎ​ทิศทาง dependency จาก​ปาก​ต้นทางThe Clean Architectureบท​แปล​ไทย​ฉบับ​เต็ม​ของ Robert C. Martin
อยาก​ได้ port/adapter จาก​ปาก​ต้นทางHexagonal Architecture (Ports and Adapters)บท​แปล​ไทย​ฉบับ​เต็ม​ของ Alistair Cockburn