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

ชั้น Domain — domain ที่​ไม่รู้จัก​โลก​ภายนอก

บท​ที่​แล้ว​เรา​วาง FoodOrdering.Domain เป็น​วงใน​สุด​ของ Clean Architecture — project ที่​ไม่มี <ProjectReference> ออก​ไป​หา​ใคร​เลย บท​นี้​เรา​จะ​เข้าไป​ดู​ข้างในกล่องนั้นจริงๆ ว่า ควร​ใส่​อะไร​ลง​ไป และ​สำคัญ​กว่า​นั้น​คือ ทำไม โครงสร้าง​ข้าง​ใน​ถึง​ต้อง​หน้าตา​แบบ​นี้ ไม่ใช่​แค่ “class กับ property” ทั่วไป

📦 code ตัวอย่าง

code เต็ม​ของ​บท​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — path: FoodOrdering.Domain/Orders/

Domain ModelDomain Modelแบบ​จำลอง​ธุรกิจ​ที่​รวม​ข้อมูล​และ​พฤติกรรม (กฎ​ธุรกิจ) ไว้​ด้วย​กัน อยู่​วงใน​สุด​ของ​สถาปัตยกรรม​และ​ไม่​พึ่งพา​สิ่ง​ภายนอก​ใด ๆTactical Design คือ​แบบ​จำลอง​ของ​ธุรกิจ​ที่ รวม​ข้อมูล​กับ​พฤติกรรม​ไว้​ใน​ที่​เดียวกัน ไม่ใช่​แค่​กอง property ที่​รอ​ให้ service ข้าง​นอก​มา​สั่ง​งาน ถ้า​ธุรกิจ​ของ​เรา​มี​กฎ​ว่า “ออเดอร์​ต้อง​มี​อย่าง​น้อยหนึ่ง​รายการ” กฎ​นั้น​ควร​อยู่ ใน code ของ Order เอง ไม่ใช่​กระจาย​อยู่​ใน​ทุก controller ที่​บังเอิญ​นึก​ขึ้น​ได้​ว่า​ต้อง​เช็ก

นี่​คือ​เหตุผล​ที่ Domain ไม่มี dependency ออก​ไป​ไหน​เลย​ตาม​ที่​เห็น​ใน .csproj เปล่าๆ ของ​บท​ที่​แล้ว — มัน​ไม่​ต้อง​รู้จัก ASP.NET ไม่​ต้อง​รู้จัก EF Core เพราะ​งาน​ของ​มัน​คือ “เป็น​ความ​จริง​เรื่อง​ธุรกิจ” ซึ่ง​เป็น​จริง​ไม่​ว่า​จะ​รัน​บน​เว็บ, คอนโซล, หรือ​แม้แต่​กระดาษ​จด​บันทึก​ของ​พนักงาน​ร้าน domain ของฟู้ด​เดลิ​เวอรี​ที่​เรา​ตาม​มา​ตั้งแต่​คอร์ส Event Storming มี​ของ​สอง​แบบ​ที่​จะ​กลาย​เป็น code ใน​บท​นี้: สิ่ง​ที่​เทียบ​กัน​ด้วย ค่า (value object) และ​สิ่ง​ที่​เทียบ​กัน​ด้วย ตัวตน (entity)

เริ่ม​จาก​ของ​ที่​เล็ก​ที่สุด​ก่อน: Money และ Quantity ทั้ง​สอง​ตัว​นี้​คือ Value ObjectValue Objectobject ที่​นิยาม​ด้วย 'ค่า' ไม่ใช่ identity เปลี่ยนแปลง​ไม่​ได้ (immutable) และ​เทียบเท่า​กัน​เมื่อ​ค่า​เท่า​กัน เช่น Money, AddressTactical Design — object ที่​ไม่มี identity เป็น​ของ​ตัวเอง เทียบ​กัน​ด้วย “ค่า​ข้าง​ใน” ล้วนๆ และ​เปลี่ยนแปลง​ไม่​ได้ (immutable) หลัง​สร้าง​เสร็จ เงิน 100 บาท​ก้อน​หนึ่ง​กับ​เงิน 100 บาท​อีก​ก้อน​หนึ่ง​คือ “ค่า​เดียวกัน” เสมอ ไม่​สำคัญ​ว่า​มัน​มา​จาก​ธุรกรรม​ไหน ต่าง​จาก Order ที่​ต่อ​ให้​สองออเดอร์​มี​รายการ​เหมือน​กัน​ทุก​อย่าง แต่​ก็​ยัง​เป็น​คน​ละ​ออเดอร์​ถ้า​คนละ OrderId

ใน C# ตัว record type ตอบ​โจทย์​นี้​พอดี เพราะ​มัน​ให้ value equality และ immutability มา​ตั้งแต่​ใน​ตัว:

FoodOrdering.Domain/Orders/Money.cs
public sealed record Money(decimal Amount, string Currency)
{
public Money
{
if (Amount < 0)
throw new ArgumentException("จำนวนเงินต้องไม่ติดลบ", nameof(Amount));
if (Currency != "THB")
throw new ArgumentException("ตอนนี้รองรับเฉพาะสกุลเงิน THB", nameof(Currency));
}
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException("บวกเงินต่างสกุลกันไม่ได้");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator *(Money unitPrice, int quantity) =>
new(unitPrice.Amount * quantity, unitPrice.Currency);
}

สังเกต public Money { ... } — เป็น primary constructor body ที่​ทำ​หน้าที่​เป็น guard clause คอย​เช็กว่า instance ที่​กำลัง​จะ​ถูก​สร้าง​ขึ้น​นั้น “ถูกต้อง​เสมอ” ตั้งแต่​วินาที​แรก ไม่มี​ทาง​สร้าง Money ที่​ติดลบ​หรือ​สกุล​เงิน​แปลกๆ หลุด​ออก​ไป​ใน​ระบบ​ได้​เลย เพราะ​จุด​สร้าง​มี​ทาง​เดียว และ​ทาง​นั้น​เช็ก​เสมอ — นี่​คือ​เหตุผล​เชิง​ปฏิบัติ​ที่ value object ควร immutable: ถ้า​ค่า​เปลี่ยน​ไม่​ได้​หลัง​สร้าง เรา​ก็​มั่นใจ​ได้​ตลอด​อายุ​ของ object ว่า​มัน​ยัง​ถูกต้อง​อยู่ ไม่​ต้อง​คอย re-validate ทุก​ครั้ง​ที่​มี​คน​อ่าน​ค่า

Quantity ก็​สร้าง​ด้วย​แนวคิด​เดียวกัน — ห่อ int เปลือยๆ ให้​กลาย​เป็น type ที่​รักษา invariant ④ ของ​มัน​เอง คือ ต้อง​ไม่​ต่ำ​กว่า 1:

FoodOrdering.Domain/Orders/Quantity.cs
public sealed record Quantity(int Value)
{
public Quantity
{
if (Value < 1)
throw new ArgumentException("จำนวนต้องมีอย่างน้อย 1", nameof(Value));
}
}

ข้อดี​ที่​ได้​แถม​มาฟรีๆ คือ compiler เอง​ก็​ช่วย​รักษา​กฎ​นี้ method ไหน​ก็ตาม​ที่​รับ parameter เป็น Quantity แทนที่​จะ​เป็น int เปลือยๆ รู้ทันที​ว่า​ค่าที่​ได้​รับ​มา​ไม่มี​ทาง​เป็น 0 หรือ​ติดลบ ไม่​ต้อง​เขียน if (qty < 1) ซ้ำ​อีก​ทุก​ที่​ที่​ใช้

ถัด​มา​คือ​ของ​ที่​มี identity: OrderLine แต่ละ​บรรทัด​ใน​ออเดอร์​คือ EntityEntityobject ที่​มี identity คงที่​ตลอด​อายุ แม้​ค่า​ข้าง​ใน​เปลี่ยน เช่น Order ที่​มี OrderIdTactical Design เพราะ​ต่อ​ให้​แก้ไข​จำนวน​สินค้า​ใน​บรรทัด​นั้น มัน​ก็​ยัง​เป็น “บรรทัด​เดิม” อยู่ (ระบุ​ด้วย id ของ​ตัวเอง ไม่ใช่​ด้วย​ค่า) แต่​คำถาม​ที่​สำคัญ​กว่า​คือ: ใคร​เป็น​คน​คุม​ว่า OrderLine พวก​นี้​ประกอบ​กัน​แล้ว “ถูกต้อง” หรือ​ไม่?

คำ​ตอบ​คือ AggregateAggregateกลุ่ม​ของ object ที่​ถือ​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง (consistency boundary) มี root เดียว​เป็น​ประตู​เข้า และ​คอย​รักษา invariant ของ​ทั้ง​กลุ่ม เช่น Order ที่​คุม OrderLineTactical DesignOrder ทำ​หน้าที่​เป็น aggregate root ที่​รวม OrderLine ไว้​เป็นกลุ่ม​เดียว และ​เป็น​ประตู​เดียว​ที่​โลก​ภายนอก​เข้าถึง​กลุ่ม​นี้​ได้ ไม่มี​ใคร​ไป​เรียก orderLine.ChangeQuantity(...) ตรงๆ จาก​ข้าง​นอก​ได้ ทุก​การ​เปลี่ยนแปลง​ต้อง​ผ่าน Order เท่านั้น เหตุผล​ไม่ใช่​เรื่อง​สไตล์​การ​เขียน code แต่​เป็น​เรื่อง ใคร​รักษา invariant ของ​ทั้ง​กลุ่ม — ถ้า​ให้​ใคร​ก็ได้​แก้ OrderLine ตรงๆ โดย​ไม่​ผ่าน Order ก็​ไม่มี​ใคร​คอย​เช็กว่า total รวม​ยัง​ถูกต้อง​อยู่​ไหม

classDiagram
    class Order {
      +OrderId Id
      +Money Total
      -List~OrderLine~ _lines
      +Place(items) Order$
    }
    class OrderLine {
      +OrderLineId Id
      +ProductId ProductId
      +Quantity Quantity
      +Money UnitPrice
    }
    class Money {
      +decimal Amount
      +string Currency
    }
    Order "1" *-- "many" OrderLine : คุม
    Order --> Money : Total
    OrderLine --> Money : UnitPrice

คำ​บรรยาย​ภาพ: Order คือ aggregate root ที่​คุม OrderLine หลาย​บรรทัด (เส้น​ทึบ​หัว​เพชร = composition) เข้าถึง OrderLine ได้​ผ่าน Order เท่านั้น ส่วน Money เป็น value object ที่​ทั้ง​สอง​ฝั่ง​ใช้​ร่วม​กัน

Order รักษา invariant สาม​ข้อ​พร้อม​กัน​ตอน​ถูก​สร้าง — ① ห้าม​ว่าง (ต้อง​มี​อย่าง​น้อยหนึ่ง​รายการ), ② Total ต้อง​เท่ากับ​ผล​รวม​ของ​ทุก​บรรทัด​เสมอ และ ④ แต่ละ​บรรทัด​ต้อง​มี Quantity อย่าง​น้อย 1 (ข้อ​นี้ Quantity เอง​รักษา​ไป​แล้ว​ตั้งแต่​ตอน​สร้าง ทำให้ Order ไม่​ต้อง​เช็ก​ซ้ำ):

FoodOrdering.Domain/Orders/Order.cs
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
public OrderId Id { get; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Money Total { get; private set; }
private readonly List<object> _domainEvents = new();
public IReadOnlyCollection<object> DomainEvents => _domainEvents.AsReadOnly();
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("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"); // invariant ①
var order = new Order(id, items);
order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow));
return order;
}
}

Order.Place(...) คือ​ทาง เดียว ที่​สร้าง​ออเดอร์​ได้ — constructor เป็น private โดย​ตั้งใจ วิธี​นี้​บังคับ​ให้​ทุกออเดอร์​ที่​มี​อยู่​ใน​ระบบ​ต้อง​ผ่านการเช็ก invariant ① มา​แล้ว​เสมอ (จะ​สร้าง​ออเดอร์​ว่างเปล่า​ด้วย​การ​เลี่ยง Place ไม่​ได้​เลย) และ Total ก็​คำนวณ​จาก _lines ให้​อัตโนมัติ (invariant ②) ไม่ใช่ field ที่​ใคร​จะ set มั่วๆ จาก​ข้าง​นอก — สังเกต​ว่า setter ของ Total เป็น private set เท่านั้น

พอ Order.Place(...) ทำงาน​สำเร็จ มัน​จะ​ปล่อย Domain EventDomain Eventสิ่ง​ที่​เกิด​ขึ้น​แล้ว​ใน domain และ​มี​ความหมาย​ต่อ​ธุรกิจ เขียน​เป็น​อดีต เช่น OrderPlaced — aggregate ปล่อย​ออก​มา​เมื่อ​สถานะ​เปลี่ยนTactical Design ชื่อ OrderPlaced ออก​มา​ด้วย — เก็บ​ไว้​ใน list _domainEvents ของ​ตัว aggregate เอง (ยัง​ไม่ dispatch ทันที รอ​ให้​ชั้น​ที่​บันทึก​ข้อมูล​เป็น​คน​ดึง​ไป​ส่ง​ต่อ​ทีหลัง ซึ่ง​เรา​จะ​เห็น​กลไก​นี้​เต็มๆ ใน​บท​ที่​ว่าด้วย Application layer) ชื่อ event เขียน​เป็นอดีตกาล เพราะ​มัน​คือ “สิ่ง​ที่​เกิด​ขึ้น​แล้ว” ใน domain ไม่ใช่​คำ​สั่ง​ให้​ทำ​อะไร:

FoodOrdering.Domain/Orders/OrderPlaced.cs
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn);

OrderPlaced เอง​ก็​เป็น​แค่ value object เล็กๆ อีก​ตัว — ห่อ​ข้อเท็จจริง​ที่​เกิด​ขึ้น ณ ขณะ​นั้น​ไว้ ให้​ชั้น​อื่น (เช่น ระบบ​แจ้ง​เตือน​ร้าน​อาหาร หรือ context อื่น​อย่าง Payment) มา​สมัคร​รับฟังได้​ทีหลัง​โดย​ไม่​ต้อง​ผูก Order เข้า​กับ​พวก​มันตรงๆ

ถ้า​คุณ​ลอง​เขียน Order ใหม่​แต่​ให้ Lines เป็น public list ที่​ใคร​ก็ Add/Remove ได้ตรงๆ และ​ย้าย​การ​คำนวณ total ไป​ไว้​ใน OrderService.CalculateTotal(order) แยก​ต่างหาก — นั่น​คือ​อาการ​ของ anemic domain model (model โลหิต​จาง) object ที่​มี​แต่ getter/setter ไร้​พฤติกรรม ส่วน​กฎ​ธุรกิจ​ทั้งหมด​หนี​ไป​กอง​อยู่​ใน service layer แทน ปัญหา​คือ​ไม่มี​อะไร​ห้าม​ไม่​ให้​ใคร​สร้าง​ออเดอร์​ว่างเปล่า หรือ​แก้ Lines แล้ว​ลืม​อัปเดต Total เพราะ invariant ไม่​ได้​ถูก​รักษา​ไว้​อยู่ ใน​ตัว object เลย มัน​ขึ้น​อยู่​กับ​ว่า​นัก​พัฒนา​ทุก​คน​จะ “จำ” ที่​จะ​เรียก service ให้​ถูก​ลำดับ​หรือ​เปล่า ซึ่ง​เป็น​สิ่ง​ที่​พึ่งพา​ไม่​ได้​ใน​ระยะ​ยาว

บท​นี้​ตั้งใจเบาเรื่อง tactical modeling — พอ​ให้​เห็น​ภาพ​ว่า Domain layer หน้าตา​แบบ​ไหน​และ​ทำไม​มัน​เขียน​แบบ​นี้ แต่​การ​ออกแบบ aggregate จริงๆ (เช่น aggregate ควร​ใหญ่​แค่​ไหน, เมื่อไร​ควร​แตก​เป็น​หลาย aggregate, invariant ข้าม​หลาย aggregate จัดการ​ยังไง) เป็น​หัวข้อ​ที่​ลึก​กว่า​นี้​มาก และ​มี​คอร์ส​แยก​ที่​ว่าด้วย​เรื่อง​นี้​โดย​เฉพาะ​อยู่​แล้ว ถ้า​อยาก​ลง​ลึก​เรื่อง aggregate design แนะนำ​ให้​ต่อ​ที่​บท Aggregates — ขอบเขต​ของ​ความ​ถูกต้อง ใน​คอร์ส DDD Patterns

บท​นี้​เรา​ปั้น FoodOrdering.Domain ให้​มี​เนื้อ​จริง: Money และ Quantity เป็น value object ที่ immutable และ​รักษา​ตัวเอง​ด้วย guard clause, Order เป็น aggregate root ที่​คุม OrderLine และ​รักษา invariant ①②④ (③ คือ​การ​แก้​ผ่าน root เท่านั้น — ถูก private constructor และ IReadOnlyCollection<OrderLine> บังคับ​ไว้​แล้ว​โดย​ธรรมชาติ​ของ​สถาปัตยกรรม​นี้) ผ่าน​ทาง Place(...) ที่​เป็น​ทาง​สร้าง​เดียว, และ OrderPlaced เป็น domain event ที่​บันทึก​ไว้​ว่า​เกิด​อะไร​ขึ้น ทั้งหมด​นี้​อยู่​ใน project ที่​ไม่รู้จัก​ฐาน​ข้อมูล ไม่รู้จัก HTTP และ​ไม่รู้จัก​อะไร​เลย​นอกจาก​ตัว​มัน​เอง

บท​ถัด​ไป​เรา​จะ​ขยับ​ออก​มา​หนึ่ง​วง สร้าง use case PlaceOrder ใน FoodOrdering.Application ที่​ทำ​หน้าที่​เรียก Order.Place(...) นี้จริงๆ พร้อม​กับ​ประกาศ repositoryRepositoryabstraction ที่​ทำให้ domain เข้าถึง/บันทึก aggregate ได้​เหมือน​เป็น in-memory collection โดย​ไม่รู้จัก​เทคโนโลยี​จัด​เก็บ​จริง — interface อยู่​วงใน implementation อยู่ InfrastructureTactical Design ที่ Application ต้องการ​ไว้​เป็น port — เพื่อ​ให้ Infrastructure มา implement ทีหลัง​ตาม​หลัก DIP ที่​วาง​ไว้​ใน​บท​ที่ 1


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

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

  • Aggregate Pattern — กลุ่ม entity ที่​มี root เดียว​คุม​การ​เข้าถึง​และ​ความ​สอดคล้อง
  • Value Object — type ที่ immutable และ​แยกแยะ​ได้​ด้วย​ค่า​เท่านั้น
  • Anemic Model — model โลหิต​จาง​ที่​มี​แต่​สถานะ ขาด​พฤติกรรม
  • Aggregates — ขอบเขต​ของ​ความ​ถูกต้อง — คอร์ส DDD Patterns ที่​ลง​ลึก​เรื่อง aggregate design เต็มๆ

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

ข้อ 1 / 3

ใครมีหน้าที่รักษา invariant ของทั้งกลุ่ม เช่น ผลรวม Total ของออเดอร์?