Aggregate & การรักษา invariant
บทที่แล้วเรายกระดับ OrderLine จาก record ที่เทียบกันด้วยค่า ให้กลายเป็น entity ที่เทียบกันด้วย identity (OrderLineId) คำถามที่ตามมาทันทีคือ — ถ้า OrderLine มีตัวตนของตัวเองแล้ว มันควรแก้ไขตัวเองได้อิสระจากภายนอกเลยไหม คำตอบของ DDD คือ ไม่ และเหตุผลคือสิ่งที่บทนี้จะพาไปดู: Order ไม่ใช่แค่ที่เก็บ OrderLine หลายชิ้นเฉยๆ มันคือขอบเขตที่รับผิดชอบ ความถูกต้องของทั้งกลุ่มร่วมกัน — ขอบเขตแบบนี้มีชื่อเรียกใน DDD ว่า AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถูกมองเป็นหนึ่งหน่วยความสอดคล้อง มีขอบเขตชัดเจน มี root เดียวเป็นประตูเข้า และเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLineTactical Design
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Domain/Orders/
ทบทวน Order จาก Course A — root ที่คุม OrderLine อยู่แล้ว
หัวข้อที่มีชื่อว่า “ทบทวน Order จาก Course A — root ที่คุม OrderLine อยู่แล้ว”ใน 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 Root เป็นประตูเดียว ห้ามแก้ OrderLine ลัดผ่าน
หัวข้อที่มีชื่อว่า “Aggregate Root เป็นประตูเดียว ห้ามแก้ 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 Addorder.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 เช่นกัน
Invariant ทั้งสี่ข้อที่ Order รักษา
หัวข้อที่มีชื่อว่า “Invariant ทั้งสี่ข้อที่ Order รักษา”InvariantInvariantกฎธุรกิจที่ 'ต้องเป็นจริงเสมอ' ภายใน Aggregate เช่น Total ต้องเท่ากับผลรวมของทุกบรรทัด — ต้องถูกต้องภายใน transaction เดียว และ aggregate root เป็นผู้รักษาTactical Design คือกฎที่ “ต้องเป็นจริงเสมอ” ตลอดอายุของ aggregate — ไม่ใช่แค่เช็กตอนสร้าง แต่ต้องยังจริงอยู่หลังทุกการเปลี่ยนแปลงด้วย Order รักษาอยู่สี่ข้อ:
- ① ออเดอร์ต้องมีอย่างน้อยหนึ่งบรรทัดเสมอ — ห้ามว่างเปล่า (guard ใน
Place:"ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้") - ②
Totalต้องเท่ากับผลรวมของUnitPrice × Quantityของทุกบรรทัดเสมอ — คำนวณใหม่ทุกครั้งที่บรรทัดเปลี่ยน ไม่ใช่ค่าที่ถูก set แยกต่างหาก - ③
OrderLineแก้ไขได้ผ่านOrderเท่านั้น — ไม่มีใครเข้าถึง_linesตรงๆ จากภายนอกได้ (รักษาด้วยprivatefield +IReadOnlyCollection<OrderLine>) - ④
Quantityของทุกบรรทัดต้องไม่ต่ำกว่า 1 เสมอ — เลขข้อเดียวกับที่ Course A บทที่ 2 ใช้เรียกกฎนี้ ข้อนี้Orderไม่ต้องเช็กเองซ้ำ เพราะมอบให้ value objectQuantityรักษาตัวเองอยู่แล้ว (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 ①②③④ ได้ครบทุกข้อในตัวมันเอง ไม่ต้องพึ่งใครจากภายนอกมาคอยเรียกให้ถูกลำดับ
Aggregate ควรใหญ่แค่ไหน — เล็กไว้ อ้างอิงกันด้วย id
หัวข้อที่มีชื่อว่า “Aggregate ควรใหญ่แค่ไหน — เล็กไว้ อ้างอิงกันด้วย id”คำถามที่ตามมาคือ แล้ว 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:
- Aggregate — นิยาม consistency boundary และ aggregate root โดยตรง
- Aggregates (คอร์ส DDD Patterns บทที่ 19) — invariant กับขอบเขต transaction แบบเต็มๆ (รวมนิยาม invariant ที่ DevIQ ไม่มีการ์ดแยก)
- Aggregates (DDD Distilled บทที่ 5) — มุมมองจากหนังสือของ Vernon เรื่องการออกแบบขอบเขต aggregate ให้เล็ก
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ขอบเขตของ Aggregate อย่าง Order ควรทำหน้าที่อะไร?