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

จาก 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 ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.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 ดิบ — 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 ไว้​ใน​ตัวเอง

เทียบ​กับ 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 ตาม​แผนที่​นี้:

  1. Value Object (บท​ที่ 2) — สร้าง​ค่าที่ “ผิด​ไม่​ได้” อย่าง Money/Quantity ให้​ลึก​ขึ้น พร้อม​กรณี VAT/PriceBreakdown
  2. Entity กับ identity (บท​ที่ 3) — ยก​ระดับ OrderLine จาก record เทียบ​ด้วย​ค่า ให้​เทียบ​ด้วย identity จริง
  3. Aggregate & invariant (บท​ที่ 4) — ทำไม Order ต้อง​เป็น​ประตู​เดียว​เข้าถึง OrderLine
  4. Order เป็น state machine (บท​ที่ 5) — Placed → Confirmed → … พร้อม policy คืน​เงิน​อัตโนมัติ​เมื่อ​ร้าน​ไม่​ยืนยัน​ใน 5 นาที
  5. Domain Events ใน code (บท​ที่ 6) — ขยาย OrderPlaced ให้​ทำงาน​ร่วม​กับ context อื่น​แบบ eventual consistency
  6. Domain Service & Specification (บท​ที่ 7) — แกะ PromotionEngine ที่​คอร์ส​ก่อน​แอบ​โชว์​ไว้​แบบ​เต็มๆ
  7. Factory & Repository contract (บท​ที่ 8) — ปิด​ท้าย​ด้วย​การ​สร้าง​และ​ค้นหา aggregate อย่าง​ถูก​วิธี

ทุก​บท​จะ​กลับ​มา​ที่ domain Order เดิม​จาก​คอร์ส Clean Architecture .NET เสมอ — ไม่มี domain ใหม่ ไม่มี project ใหม่ มี​แต่​เนื้อที่​หนา​ขึ้น​เรื่อยๆ ใน​กล่อง​เดิม


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Anemic Model — model กลวง​ที่​มี​แต่​สถานะ ขาด​พฤติกรรม
  • Ubiquitous Language — ภาษา​กลาง​ระหว่าง​ทีม​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ
  • Domain Modeling — Building Blocks — ภาพ​รวม building blocks ทั้ง​ชุด​ที่​คอร์ส​นี้​จะ​ไล่​ทำที​ละ​ตัว

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

อาการของ domain กลวง (anemic domain model) คือข้อใด?