ชั้น Domain — domain ที่ไม่รู้จักโลกภายนอก
บทที่แล้วเราวาง FoodOrdering.Domain เป็นวงในสุดของ Clean Architecture — project ที่ไม่มี <ProjectReference> ออกไปหาใครเลย บทนี้เราจะเข้าไปดูข้างในกล่องนั้นจริงๆ ว่า ควรใส่อะไรลงไป และสำคัญกว่านั้นคือ ทำไม โครงสร้างข้างในถึงต้องหน้าตาแบบนี้ ไม่ใช่แค่ “class กับ property” ทั่วไป
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)
Value Object — Money และ Quantity
หัวข้อที่มีชื่อว่า “Value Object — Money และ Quantity”เริ่มจากของที่เล็กที่สุดก่อน: Money และ Quantity ทั้งสองตัวนี้คือ Value ObjectValue Objectobject ที่นิยามด้วย 'ค่า' ไม่ใช่ identity เปลี่ยนแปลงไม่ได้ (immutable) และเทียบเท่ากันเมื่อค่าเท่ากัน เช่น Money, AddressTactical Design — object ที่ไม่มี identity เป็นของตัวเอง เทียบกันด้วย “ค่าข้างใน” ล้วนๆ และเปลี่ยนแปลงไม่ได้ (immutable) หลังสร้างเสร็จ เงิน 100 บาทก้อนหนึ่งกับเงิน 100 บาทอีกก้อนหนึ่งคือ “ค่าเดียวกัน” เสมอ ไม่สำคัญว่ามันมาจากธุรกรรมไหน ต่างจาก Order ที่ต่อให้สองออเดอร์มีรายการเหมือนกันทุกอย่าง แต่ก็ยังเป็นคนละออเดอร์ถ้าคนละ OrderId
ใน C# ตัว record type ตอบโจทย์นี้พอดี เพราะมันให้ value equality และ immutability มาตั้งแต่ในตัว:
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:
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) ซ้ำอีกทุกที่ที่ใช้
Entity และ Aggregate — Order คุม OrderLine
หัวข้อที่มีชื่อว่า “Entity และ Aggregate — Order คุม OrderLine”ถัดมาคือของที่มี identity: OrderLine แต่ละบรรทัดในออเดอร์คือ EntityEntityobject ที่มี identity คงที่ตลอดอายุ แม้ค่าข้างในเปลี่ยน เช่น Order ที่มี OrderIdTactical Design เพราะต่อให้แก้ไขจำนวนสินค้าในบรรทัดนั้น มันก็ยังเป็น “บรรทัดเดิม” อยู่ (ระบุด้วย id ของตัวเอง ไม่ใช่ด้วยค่า) แต่คำถามที่สำคัญกว่าคือ: ใครเป็นคนคุมว่า OrderLine พวกนี้ประกอบกันแล้ว “ถูกต้อง” หรือไม่?
คำตอบคือ AggregateAggregateกลุ่มของ object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง (consistency boundary) มี root เดียวเป็นประตูเข้า และคอยรักษา invariant ของทั้งกลุ่ม เช่น Order ที่คุม OrderLineTactical Design — Order ทำหน้าที่เป็น 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 ไม่ต้องเช็กซ้ำ):
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 เท่านั้น
Domain Event — OrderPlaced
หัวข้อที่มีชื่อว่า “Domain Event — OrderPlaced”พอ Order.Place(...) ทำงานสำเร็จ มันจะปล่อย Domain EventDomain Eventสิ่งที่เกิดขึ้นแล้วใน domain และมีความหมายต่อธุรกิจ เขียนเป็นอดีต เช่น OrderPlaced — aggregate ปล่อยออกมาเมื่อสถานะเปลี่ยนTactical Design ชื่อ OrderPlaced ออกมาด้วย — เก็บไว้ใน list _domainEvents ของตัว aggregate เอง (ยังไม่ dispatch ทันที รอให้ชั้นที่บันทึกข้อมูลเป็นคนดึงไปส่งต่อทีหลัง ซึ่งเราจะเห็นกลไกนี้เต็มๆ ในบทที่ว่าด้วย Application layer) ชื่อ event เขียนเป็นอดีตกาล เพราะมันคือ “สิ่งที่เกิดขึ้นแล้ว” ใน domain ไม่ใช่คำสั่งให้ทำอะไร:
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn);OrderPlaced เองก็เป็นแค่ value object เล็กๆ อีกตัว — ห่อข้อเท็จจริงที่เกิดขึ้น ณ ขณะนั้นไว้ ให้ชั้นอื่น (เช่น ระบบแจ้งเตือนร้านอาหาร หรือ context อื่นอย่าง Payment) มาสมัครรับฟังได้ทีหลังโดยไม่ต้องผูก Order เข้ากับพวกมันตรงๆ
กันโรค anemic
หัวข้อที่มีชื่อว่า “กันโรค anemic”ถ้าคุณลองเขียน 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:
- Aggregate Pattern — กลุ่ม entity ที่มี root เดียวคุมการเข้าถึงและความสอดคล้อง
- Value Object — type ที่ immutable และแยกแยะได้ด้วยค่าเท่านั้น
- Anemic Model — model โลหิตจางที่มีแต่สถานะ ขาดพฤติกรรม
- Aggregates — ขอบเขตของความถูกต้อง — คอร์ส DDD Patterns ที่ลงลึกเรื่อง aggregate design เต็มๆ
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ใครมีหน้าที่รักษา invariant ของทั้งกลุ่ม เช่น ผลรวม Total ของออเดอร์?