จาก domain กลวงสู่ domain มีชีวิต
ในคอร์ส Clean Architecture ด้วย .NET เราวางโครงทั้ง4 project FoodOrdering.Domain / .Application / .Infrastructure / .Web ตาม Dependency Rule แล้วปั้น Order เป็น aggregate root ที่คุม OrderLine — แต่ถ้าคุณสังเกตดีๆ บทที่ 2 ชั้น Domain ของคอร์สนั้นตั้งใจทำให้ FoodOrdering.Domain บาง เอาไว้ก่อน แค่พอให้เห็นภาพว่า value object, entity และ aggregate หน้าตาแบบไหน ส่วน tactical pattern ที่ลึกกว่านั้น — state machine, domain event เต็มรูป, specification, factory — ถูกเก็บไว้ให้ “คอร์สถัดไปในซีรีส์” ตามที่บทที่ 3 และบทที่ 7 ของคอร์สนั้นสัญญาไว้
คอร์สนั้นคือคอร์สนี้เอง เราจะไม่สอนโครง .csproj, การต่อ EF Core หรือ composition root ซ้ำอีก — ของพวกนั้นนิ่งแล้วตั้งแต่คอร์สก่อน สิ่งที่คอร์สนี้ทำคือ เข้าไปเติมเนื้อใน domain Order เดิม ให้กฎธุรกิจที่เคยกระจัดกระจายอยู่ตามที่ต่างๆ กลับมาอยู่ในตัว object ที่ควรเป็นเจ้าของมัน
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/
domain กลวงคืออะไร
หัวข้อที่มีชื่อว่า “domain กลวงคืออะไร”พอไม่มีใครตั้งใจดูแล domain มักจะค่อยๆ กลายเป็น โดเมนกลวงAnemic Domain Modelanti-pattern ที่อ็อบเจ็กต์มีแต่ getter/setter ไร้พฤติกรรม (domain 'กลวง') กฎธุรกิจถูกดึงไปไว้ใน service ทั้งหมด — จ่ายค่า domain model แต่ไม่ได้ประโยชน์ของมันเลยTactical Design (anemic domain model) — object ที่มีแต่ property แบบ get/set ล้วนๆ ไม่มีพฤติกรรมของตัวเองเลย ส่วนกฎธุรกิจทั้งหมดถูกดึงออกไปกองอยู่ใน service ชั้นนอกแทน คอร์ส Clean Architecture .NET เคยเตือนเรื่องนี้สั้นๆ ในบทที่ 2 ด้วยชื่อ “model โลหิตจาง” แล้ว — ในคอร์สนี้เราจะเรียกมันว่า domain กลวง และให้มันเป็นผู้ร้ายของคอร์สทั้งหมด เพราะทุกบทที่เหลือคือการแก้ปัญหานี้ทีละมุม
ลองจินตนาการว่าเราไม่เคยเรียนคอร์สก่อน แล้วเขียน Order แบบสามัญสำนึกทั่วไปแทน:
// ❌ version ดิบ — ไม่มีใครห้ามอะไรเลยpublic class Order{ public OrderId Id { get; set; } public List<OrderLine> Lines { get; set; } = new(); public Money Total { get; set; }}
public class OrderService{ public void CalculateTotal(Order order) { order.Total = order.Lines .Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value); }}ปัญหาโผล่ทันทีที่มีใครใช้งานมันแบบนี้:
var order = new Order { Lines = new List<OrderLine> { line1 } };orderService.CalculateTotal(order); // order.Total = 100 บาท ถูกต้อง
order.Lines.Add(line2); // เพิ่มรายการตรง ๆ ได้เลย — ไม่มีใครห้าม// ...แล้วลืมเรียก orderService.CalculateTotal(order) อีกครั้ง
// order.Total ยังเป็น 100 บาท ทั้งที่ Lines มี line2 เพิ่มเข้ามาแล้วไม่มีบรรทัดไหนใน Order ผิด compile แต่ระบบกลับพังเงียบๆ เพราะไม่มีอะไรบังคับให้ Total ตรงกับ Lines เสมอ — มันขึ้นอยู่กับว่านักพัฒนาทุกคน “จำ” ที่จะเรียก CalculateTotal ให้ถูกลำดับหรือเปล่า ซึ่งเป็นสิ่งที่พึ่งพาไม่ได้ในระยะยาว (ดูเพิ่มที่ Anemic Model ใน DevIQ)
flowchart TB
subgraph Anemic["domain กลวง (anemic)"]
O1["Order<br/>(get/set ล้วน)"] -.->|"กฎอยู่ข้างนอก"| S1["OrderService<br/>CalculateTotal()"]
end
subgraph Rich["domain มีชีวิต (rich)"]
O2["Order<br/>Place() • Total (คำนวณเอง)<br/>รักษา invariant ในตัว"]
end
Anemic -->|"คอร์สนี้พาข้าม"| Rich
คำบรรยายภาพ: domain กลวงปล่อยให้กฎหนีไปอยู่ service ภายนอก ส่วน domain มีชีวิตเก็บพฤติกรรมและ invariant ไว้ในตัวเอง
ทำไม domain มีชีวิตถึงกัน bug ได้
หัวข้อที่มีชื่อว่า “ทำไม domain มีชีวิตถึงกัน bug ได้”เทียบกับ Order ตัวจริงที่คอร์ส Clean Architecture .NET ปั้นไว้แล้วในบทที่ 2:
// จาก FoodOrdering.Domain/Orders/Order.cs (คอร์ส Clean Architecture .NET บทที่ 2)public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<object> _domainEvents = new(); public OrderId Id { get; } public Money Total { get; private set; } // set ได้จากใน class เท่านั้น
private Order(OrderId id, IEnumerable<OrderLine> lines) { Id = id; _lines.AddRange(lines); Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value); }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items); order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow)); return order; }}จุดต่างอยู่ที่ว่า Order.Place(...) คือทาง เดียว ที่สร้างออเดอร์ได้ (constructor เป็น private) และ Total มี setter เป็น private เท่านั้น จึงไม่มีทางที่ใครจะสร้างออเดอร์ว่างเปล่า หรือแก้ Lines แล้วปล่อยให้ Total ค้างเก่าเหมือน version ดิบข้างบน — invariant ถูกรักษาไว้ ในตัว object เอง ไม่ต้องพึ่งว่านักพัฒนาจะจำได้หรือเรียก service ให้ถูกลำดับ
ภาษาที่ต้องตรงกับธุรกิจ
หัวข้อที่มีชื่อว่า “ภาษาที่ต้องตรงกับธุรกิจ”สังเกตชื่อ method ใน Order ตัวจริง: Place, และในบทหลังๆ เราจะเจอ Confirm, Cancel, PromotionEngine — ไม่มีตัวไหนชื่อ Insert, Update, Delete, หรือ Process แบบที่ CRUD ทั่วไปนิยมใช้ นี่ไม่ใช่เรื่องรสนิยม แต่คือหลักการที่เรียกว่า Ubiquitous LanguageUbiquitous Languageภาษากลางที่ทีมพัฒนาและผู้เชี่ยวชาญธุรกิจใช้ร่วมกัน อิงกับ domain model โดยตรง คำเดียวกันหมายถึงสิ่งเดียวกัน และสะท้อนลงในชื่อ type/method ใน codeStrategic Design — ภาษากลางที่ทีมพัฒนาและผู้เชี่ยวชาญธุรกิจใช้ร่วมกัน คนทำร้านอาหารไม่พูดว่า “insert ออเดอร์” เขาพูดว่า “วางออเดอร์” (place) “ยืนยันออเดอร์” (confirm) หรือ “ยกเลิกออเดอร์” (cancel) — พอชื่อ method ใน code ตรงกับคำที่ธุรกิจใช้จริง ทั้งสองฝั่งก็คุยกันด้วยคำเดียวกันได้ตรงๆ ไม่ต้องแปลไปแปลมา และเวลาอ่าน code ก็อ่านออกว่ากำลังทำอะไรในภาษาธุรกิจ ไม่ใช่แค่ “แก้ record ในตาราง”
อยากลงลึกเรื่องนี้ต่อ อ่านได้ที่ Ubiquitous Language ใน DevIQ และบทความ Ubiquitous Language ในยุคที่ AI ช่วยเขียน code ที่ต่อยอดว่าทำไมภาษากลางนี้ยิ่งสำคัญขึ้นเมื่อมี AI มาช่วยเขียน code ด้วย
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้ตั้งชื่อผู้ร้ายของทั้งคอร์สไว้แล้ว: domain กลวง — object ที่มีแต่ getter/setter ให้กฎธุรกิจหนีไปกองอยู่ใน service ภายนอก ต่างจาก domain มีชีวิตที่รักษา invariant ไว้ในตัวเอง และตั้งชื่อ method ตาม ubiquitous language ของธุรกิจ ไม่ใช่ตาม CRUD
จากนี้ไปเราจะไล่เติมเนื้อให้ Order ทีละ building block ตามแผนที่นี้:
- Value Object (บทที่ 2) — สร้างค่าที่ “ผิดไม่ได้” อย่าง
Money/Quantityให้ลึกขึ้น พร้อมกรณี VAT/PriceBreakdown - Entity กับ identity (บทที่ 3) — ยกระดับ
OrderLineจาก record เทียบด้วยค่า ให้เทียบด้วย identity จริง - Aggregate & invariant (บทที่ 4) — ทำไม
Orderต้องเป็นประตูเดียวเข้าถึงOrderLine - Order เป็น state machine (บทที่ 5) — Placed → Confirmed → … พร้อม policy คืนเงินอัตโนมัติเมื่อร้านไม่ยืนยันใน 5 นาที
- Domain Events ใน code (บทที่ 6) — ขยาย
OrderPlacedให้ทำงานร่วมกับ context อื่นแบบ eventual consistency - Domain Service & Specification (บทที่ 7) — แกะ
PromotionEngineที่คอร์สก่อนแอบโชว์ไว้แบบเต็มๆ - Factory & Repository contract (บทที่ 8) — ปิดท้ายด้วยการสร้างและค้นหา aggregate อย่างถูกวิธี
ทุกบทจะกลับมาที่ domain Order เดิมจากคอร์ส Clean Architecture .NET เสมอ — ไม่มี domain ใหม่ ไม่มี project ใหม่ มีแต่เนื้อที่หนาขึ้นเรื่อยๆ ในกล่องเดิม
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Anemic Model — model กลวงที่มีแต่สถานะ ขาดพฤติกรรม
- Ubiquitous Language — ภาษากลางระหว่างทีมพัฒนาและผู้เชี่ยวชาญธุรกิจ
- Domain Modeling — Building Blocks — ภาพรวม building blocks ทั้งชุดที่คอร์สนี้จะไล่ทำทีละตัว
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3อาการของ domain กลวง (anemic domain model) คือข้อใด?