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

Aggregate & การ​รักษา invariant

บท​ที่​แล้ว​เรา​ยก​ระดับ OrderLine จาก record ที่​เทียบ​กัน​ด้วย​ค่า ให้​กลาย​เป็น entity ที่​เทียบ​กัน​ด้วย identity (OrderLineId) คำถาม​ที่​ตาม​มา​ทันที​คือ — ถ้า OrderLine มี​ตัวตน​ของ​ตัวเอง​แล้ว มัน​ควร​แก้ไข​ตัวเอง​ได้​อิสระ​จาก​ภายนอก​เลย​ไหม คำ​ตอบ​ของ DDD คือ ไม่ และ​เหตุผล​คือ​สิ่ง​ที่​บท​นี้​จะ​พา​ไป​ดู: Order ไม่ใช่​แค่​ที่​เก็บ OrderLine หลาย​ชิ้น​เฉยๆ มัน​คือ​ขอบเขต​ที่​รับผิดชอบ ความ​ถูกต้อง​ของ​ทั้ง​กลุ่ม​ร่วม​กัน — ขอบเขต​แบบ​นี้​มีชื่อ​เรียก​ใน DDD ว่า AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง มี​ขอบเขต​ชัดเจน มี root เดียว​เป็น​ประตู​เข้า และ​เป็น​หน่วย​ของ transaction เช่น Order ที่​คุม OrderLineTactical Design

📦 code ตัวอย่าง

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

ใน Course A บท​ที่ 2 เรา​เห็น Order เป็น​ตัว​คุม OrderLine มา​ตั้งแต่​ต้น ก่อน​จะ​ไป​ต่อ ขอ​ทวน type ที่​เกี่ยวข้อง​ทั้งหมด​ให้​ครบ — ทุก​ตัว​เหมือน​เดิม​ทุก​ตัว​อักษร​ตาม​ที่ Course A ประกาศ​ไว้ ยกเว้น OrderLine ที่​ทวน​ตาม version ที่​ยก​ระดับ​เป็น entity ไป​แล้ว​ใน​บท​ที่​แล้ว​ของ​คอร์ส​นี้:

public sealed record OrderId(Guid Value);
public sealed record OrderLineId(Guid Value);
public sealed record ProductId(Guid Value);
public sealed record Quantity(int Value); // guard: Value < 1 → ArgumentException "จำนวนต้องมีอย่างน้อย 1"
public sealed record Money(decimal Amount, string Currency) // guard: Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"
{
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้"
public static Money operator *(Money unitPrice, int quantity);
}
// OrderLine — เทียบเท่ากันด้วย Id เพียงอย่างเดียว (ยกระดับจาก record ไปเป็น entity ในบทที่แล้ว)
public sealed class OrderLine : Entity<OrderLineId> // Entity<TId>.Equals/GetHashCode เทียบด้วย Id ล้วน ๆ ไม่แตะ ProductId/Quantity/UnitPrice เลย
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice)
: base(id)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn);

และ Order เอง — aggregate root ตัว​จริง:

// FoodOrdering.Domain/Orders/Order.cs (aggregate root จาก Course A บทที่ 2)
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("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items);
order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow));
return order;
}
}

สังเกต​ว่า constructor เป็น private — วิธี​เดียว​ที่​สร้าง Order ได้​คือ​ผ่าน Place(...) ซึ่ง​เช็ก guard ก่อน​เสมอ นี่​คือ​จุด​ตั้งต้น​ของ aggregate: กลุ่ม​ของ entity และ value object ที่​ถูก​มอง​เป็น หนึ่ง​หน่วย​ความ​สอดคล้อง (consistency boundary) — Order กับ OrderLine ทุก​บรรทัด​ของ​มัน​ไม่ใช่​วัตถุ​อิสระ​ที่​บังเอิญ​มา​อยู่​ด้วย​กัน แต่​เป็นกลุ่ม​ที่​ต้อง “ถูกต้อง​พร้อม​กัน” เสมอ และ Order คือ​ตัว​ที่​ถูก​เลือก​ให้​เป็น root ของ​กลุ่ม​นี้ — ทุก​การ​อ่าน/เขียน​ที่มา​จาก​ภายนอก​ขอบเขต​ต้อง​ผ่าน Order เท่านั้น ไม่มี​ทาง​เข้า​ตรง​ถึง OrderLine ได้​เลย

aggregate rootAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น 'ประตู​เดียว' เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design ไม่ใช่​แค่​ชื่อ​เรียกสวยๆ — มัน​คือ​กฎ​ที่​บังคับ​จริง​ใน code ลอง​ดู​ว่า​จะ​เกิด​อะไร​ขึ้น​ถ้า Order เผลอ​เปิด​ทาง​ลัด​ให้​แก้ Lines ตรงๆ จาก​ภายนอก​แทน (ไม่ใช่ code จริง​ของ Course A):

// ถ้า Order เผลอเปิดทางลัดแบบนี้ — ไม่ใช่สิ่งที่ Course A ทำ
public List<OrderLine> Lines { get; set; } = new(); // setter เปิดโล่ง ใครก็แก้ตรง ๆ ได้

แบบ​นี้​ใคร​ก็​เพิ่ม/ลบ/สลับ OrderLine ได้​อิสระ​โดย​ไม่​ผ่าน guard ใดๆ เลย และ Total ก็​จะ​ค้าง​ค่า​เก่า​ทันที — invariant พัง​แบบ​เดียว​กับ “version ดิบ” ที่​บท​ที่ 1 เคย​เจอ (Order กลวง​ที่​ต้อง​พึ่ง OrderService.CalculateTotal แยก​ต่างหาก) Order ตัว​จริง​ไม่​เปิด​ช่อง​นี้​เลย: Lines คืน IReadOnlyCollection<OrderLine> (ไม่มี Add/Remove/setter) และ​ตัว OrderLine เอง​ก็ immutable เช่น​กัน (property เป็น get-only ตั้ง​ค่า​ได้​แค่​ตอน​สร้าง​ผ่าน constructor) ลอง​ไล่​ดู​ว่า​อะไร compile ไม่​ผ่าน​บ้าง:

var order = Order.Place(orderId, items);
order.Lines.Add(newLine); // ❌ compile error — IReadOnlyCollection<T> ไม่มี method Add
order.Lines.First().Quantity = new Quantity(3); // ❌ compile error — Quantity เป็น get-only property ไม่มี public setter

ทั้ง​สอง​บรรทัด​ผิด​ตั้งแต่ compile time ไม่ใช่​แค่ runtime — เพราะ​ฉะนั้น​ทาง​เดียว​ที่​จะ​เปลี่ยนแปลง OrderLine ได้​จริง​คือ​ผ่าน behavior method ที่ Order เปิด​ให้ ไม่ใช่ setter ที่​ให้​ใคร​ก็​ยัด​ค่า​อะไร​เข้าไป​ก็ได้

flowchart TB
  Ext1["PlaceOrderHandler<br/>(Application layer)"] -->|"เรียกผ่าน"| Root
  Ext2["CustomerId<br/>(อ้างอิงข้าม aggregate)"] -.->|"อ้างด้วย id เท่านั้น ไม่แตะข้างใน"| Root
  subgraph Boundary["Order — ขอบเขตความสอดคล้องเดียว"]
    Root["Order<br/>(aggregate root)<br/>Place() • ChangeQuantity()"]
    L1["OrderLine #1"]
    L2["OrderLine #2"]
    T["Total<br/>(คำนวณจากทุกบรรทัดเสมอ)"]
    Root --> L1
    Root --> L2
    Root --> T
  end

คำ​บรรยาย​ภาพ: arrow จาก​ภายนอก​ทุก​เส้น​พุ่ง​เข้า​ที่ Order เท่านั้น — ไม่มี​ใคร​แตะ OrderLine หรือ Total ตรงๆ จาก​นอก​ขอบเขต แม้แต่​การ​อ้างอิง​จาก aggregate อื่น (เช่น CustomerId) ก็​ทำได้​แค่​ผ่าน Order เช่น​กัน

InvariantInvariantกฎ​ธุรกิจ​ที่ 'ต้อง​เป็น​จริง​เสมอ' ภายใน Aggregate เช่น Total ต้อง​เท่ากับ​ผล​รวม​ของ​ทุก​บรรทัด — ต้อง​ถูกต้อง​ภายใน transaction เดียว และ aggregate root เป็น​ผู้​รักษาTactical Design คือ​กฎ​ที่ “ต้อง​เป็น​จริง​เสมอ” ตลอด​อายุ​ของ aggregate — ไม่​ใช่​แค่​เช็ก​ตอน​สร้าง แต่​ต้อง​ยัง​จริง​อยู่​หลัง​ทุก​การ​เปลี่ยนแปลง​ด้วย Order รักษา​อยู่​สี่​ข้อ:

  1. ออเดอร์​ต้อง​มี​อย่าง​น้อยหนึ่ง​บรรทัด​เสมอ — ห้าม​ว่างเปล่า (guard ใน Place: "ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้")
  2. Total ต้อง​เท่ากับ​ผล​รวม​ของ UnitPrice × Quantity ของ​ทุก​บรรทัด​เสมอ — คำนวณ​ใหม่​ทุก​ครั้ง​ที่​บรรทัด​เปลี่ยน ไม่ใช่​ค่าที่​ถูก set แยก​ต่างหาก
  3. OrderLine แก้ไข​ได้​ผ่าน Order เท่านั้น — ไม่มี​ใคร​เข้าถึง _lines ตรงๆ จาก​ภายนอก​ได้ (รักษา​ด้วย private field + IReadOnlyCollection<OrderLine>)
  4. Quantity ของ​ทุก​บรรทัด​ต้อง​ไม่​ต่ำ​กว่า 1 เสมอ — เลข​ข้อ​เดียว​กับ​ที่ Course A บท​ที่ 2 ใช้​เรียก​กฎ​นี้ ข้อ​นี้ Order ไม่​ต้อง​เช็ก​เอง​ซ้ำ เพราะ​มอบ​ให้ value object Quantity รักษา​ตัวเอง​อยู่​แล้ว (guard clause ที่​ทวน​ไว้​ด้าน​บน)

สังเกต​ว่า​แม้​ข้อ ④ จะ​รักษา​โดย Quantity เอง ไม่ใช่ Order โดยตรง — แต่ Order ก็​ยัง​ถือ​เป็น​ผู้รับผิดชอบ​สูงสุด​ของ​ทั้ง​กลุ่ม เพราะ​ไม่มี​ทาง​ที่ OrderLine ที่​มี Quantity ผิด​กฎ​จะ​เล็ดลอด​เข้า​มา​อยู่​ใน _lines ได้​เลย​ตราบ​ใด​ที่​การ​สร้าง Quantity ทุก​ครั้ง​ต้อง​ผ่าน guard ของ​มัน​ก่อน​เสมอ นี่​คือ​ความหมาย​ของ “aggregate root รักษา invariant ของ​ทั้ง​กลุ่ม” — บาง​ข้อ root เช็ก​เอง บาง​ข้อ root มอบ​ให้ value object ข้าง​ใน​เช็ก​แทน แต่ root คือ​ผู้​ที่​รับประกัน​ว่า​ไม่มี​ทาง​สร้าง​สถานะ​ที่​ผิด​กฎ​ขึ้น​มา​ได้​เลย​ไม่​ว่า​ทาง​ไหน

ตอน​นี้​ลอง​เพิ่ม​พฤติกรรม​ใหม่​ให้ Order — เปลี่ยน​จำนวน​ของ​บรรทัด​ที่​มี​อยู่​แล้ว เพื่อ​ดู​ว่า​ข้อ ② ยัง​คง​จริง​อยู่​หลัง​การ​แก้ไข​ได้​อย่างไร:

// เพิ่มเข้าไปใน Order class ด้านบน (field/property ครบตามที่ประกาศไว้แล้วทั้งหมด)
public void ChangeQuantity(OrderLineId lineId, Quantity quantity)
{
var index = _lines.FindIndex(line => line.Id == lineId);
if (index == -1)
throw new InvalidOperationException("ไม่พบบรรทัดนี้ในออเดอร์ แก้ไขไม่ได้");
var existing = _lines[index];
_lines[index] = new OrderLine(existing.Id, existing.ProductId, quantity, existing.UnitPrice);
Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value);
}

OrderLine ยัง immutable เหมือน​เดิม — “แก้ไข” ใน​ที่​นี้​คือ​สร้าง OrderLine ก้อน​ใหม่​ผ่าน constructor (คัด​ลอก field เดิม​มา​ทั้งหมด ยกเว้น Quantity ที่​เปลี่ยน — เทียบ​เท่ากับ with { Quantity = quantity } ของ record แต่​ต้อง​เรียก constructor ตรงๆ เพราะ​ตอน​นี้ OrderLine เป็น entity ไม่ใช่ record แล้ว) แล้ว​แทนที่​ก้อน​เดิม​ใน _lines (list ที่​เป็น private เข้า​ไม่​ถึง​จาก​ข้าง​นอก) จุด​สำคัญ​ที่สุด​คือ​บรรทัด​สุดท้าย: สูตร​คำนวณ Total เหมือนกันเป๊ะ กับ​ที่ constructor ใช้​ตอน​สร้าง​ครั้ง​แรก — ทุก method ที่​แตะ _lines ต้อง​คำนวณ Total ใหม่​ทันที​ใน method เดียวกัน ไม่มี​การ​ปล่อย​ให้ Total “ค้าง” รอ​ใคร​มา​เรียก​อัปเดต​ทีหลัง​แบบ​ที่ OrderService.CalculateTotal ใน version กลวง​ของ​บท​ที่ 1 เคย​ทำ นี่​คือ​หลักฐาน​ว่า Order.ChangeQuantity(...) คือ behavior method ที่​ตั้ง​ชื่อ​ตาม​ภาษา​ธุรกิจ (“เปลี่ยน​จำนวน”) ไม่ใช่ setter ที่​เปิด​ให้​แก้ state แบบดิบๆ — และ​เพราะ​มัน​เป็น method ของ Order เอง มัน​จึง​รักษา invariant ①②③④ ได้​ครบ​ทุก​ข้อ​ใน​ตัว​มัน​เอง ไม่​ต้อง​พึ่ง​ใคร​จาก​ภายนอก​มา​คอย​เรียก​ให้​ถูก​ลำดับ

คำถาม​ที่​ตาม​มา​คือ แล้ว Order ควร​ฝัง​อะไร​ไว้​ข้าง​ใน​บ้าง ลูกค้า​ที่​สั่ง? ร้าน​อาหาร? ประวัติการ​ชำระ​เงิน​ทั้งหมด? กฎ​ของ DDD ชัดเจน: ให้ aggregate เล็ก​ที่สุด​เท่า​ที่ invariant ยัง​ถูก​รักษา​ไว้​ครบ เพราะ aggregate คือ​หน่วย​ของ transaction ด้วย — ยิ่ง aggregate ใหญ่ ยิ่ง​มี​โอกาส​ที่2 request ที่​ไม่​เกี่ยวข้อง​กัน​เลย​จะ​มา​ชน​กัน​ตอน lock แถว​เดียวกัน​ใน​ฐาน​ข้อมูล ทั้งที่จริงๆ ไม่มี​กฎ​ธุรกิจ​ข้อ​ไหน​บังคับ​ให้​ต้อง “ล็อก​พร้อม​กัน” เลย

สิ่ง​ที่ Order ต้อง​รู้​เกี่ยว​กับ aggregate อื่น จึง​เป็น​แค่ id ไม่ใช่​ก้อน​ข้อมูล​ทั้งหมด:

// ตัวอย่างสมมติ — ถ้า Order ต้องรู้ว่าใครเป็นคนสั่ง
public sealed record CustomerId(Guid Value);

ถ้า Order ต้อง​ผูก​กับ​ลูกค้า มัน​จะ​เก็บ​แค่ CustomerId CustomerId { get; } ไม่ใช่ Customer Customer { get; } ก้อน​เต็ม — เพราะ Customer คือ aggregate ของ​ตัวเอง มี invariant และ​ขอบเขต transaction ของ​ตัวเอง แยก​จาก Order โดย​สิ้นเชิง กฎ​ง่ายๆ ที่​จำ​ได้​ทันที​คือ 1 transaction แก้​ได้​ไม่​เกิน1 aggregate ถ้า​การกระทำ​หนึ่ง​ครั้ง​ต้อง​เปลี่ยน​สถานะ​ของ2 aggregate พร้อม​กัน (เช่น วางออเดอร์​แล้ว​ต้อง​หัก​แต้ม​สะสม​ของ​ลูกค้า​ด้วย) คำ​ตอบ​ไม่ใช่​ยัด​ทั้ง​สอง​ก้อน​ไว้​ใน transaction เดียว แต่​คือ​ให้ Order ประกาศ​ว่า “เกิด​เหตุการณ์​นี้​แล้ว” แล้ว​ปล่อย​ให้​ส่วน​อื่น​ตอบ​สนอง​ทีหลัง — นั่น​คือ​หน้าที่​ของ Domain Events ที่​บท​ที่ 6 (Domain Events ใน code) จะ​พา​ไป​ดู​แบบ​เต็มๆ ไม่ใช่​การ​เพิ่ม aggregate ตัว​ที่​สอง​เข้า​มา​ใน code เดียวกัน​นี้

Order เป็น aggregate เพราะ​มัน​คือ​ขอบเขต​ความ​สอดคล้อง​เดียว​ที่​รวม OrderLine ทุก​บรรทัด​กับ Total ไว้​ด้วย​กัน มี aggregate root เป็น​ประตู​เดียว​เข้าถึง (constructor private, Lines เป็น IReadOnlyCollection, ไม่มี setter ให้​ใคร​แก้​ลัด​ผ่าน) และ​รักษา invariant สี่​ข้อ (①ห้าม​ว่าง ②Total ต้อง​ตรง​เสมอ ③แก้​ได้​ผ่าน root เท่านั้น ④Quantity≥1) ทุก method ที่​แตะ state ข้าง​ใน​ต้อง​คำนวณ​ใหม่​ให้​ครบ​ก่อน​จบ ไม่​ปล่อย​ให้​ค้าง​แบบ domain กลวง ส่วน​ขนาด​ของ aggregate ให้​ยึด​หลัก “เล็ก​ไว้ อ้างอิง​ตัว​อื่น​ด้วย id”

บท​ถัด​ไป (บท​ที่ 5) เรา​จะ​เปลี่ยน Order ให้​เป็น state machine เต็ม​รูปแบบ — Placed → Confirmed → … พร้อม policy คืน​เงิน​อัตโนมัติ​เมื่อ​ร้าน​ไม่​ยืนยัน​ใน 5 นาที ซึ่ง​จะ​เห็น​ชัด​ว่า invariant ที่​เพิ่ง​รักษา​ใน​บท​นี้​ต้อง​ยัง​คง​จริง​อยู่​แม้​ตอน​ที่​ออเดอร์​เปลี่ยน​สถานะ​ไป​เรื่อยๆ ด้วย


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

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

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

ข้อ 1 / 3

ขอบเขตของ Aggregate อย่าง Order ควรทำหน้าที่อะไร?