Collections และ LINQ — รักษา invariant ของกลุ่ม
จนถึงตอนนี้เราปั้นก้อนอิฐเดี่ยวๆ ของ domain Order มาหลายตัวแล้ว — Money, Quantity, OrderId เป็น Value ObjectValue Objectอ็อบเจ็กต์ที่นิยามด้วย 'ค่า' ไม่มี identity ของตัวเอง สองตัวที่ค่าเท่ากันถือว่าเป็นสิ่งเดียวกัน ควร immutable เปลี่ยนแปลงไม่ได้หลังสร้าง — ใน C# มักสร้างด้วย record เช่น Money, OrderIdTactical Design ที่เขียนด้วย record และ OrderLine เป็น entity ที่มีตัวตนของตัวเอง บทนี้เราจะเอาชิ้นส่วนพวกนั้นมาประกอบเป็นกลุ่ม: ออเดอร์หนึ่งใบมีหลายบรรทัด (OrderLine) รวมกันอยู่ข้างใน แล้ว Order ต้องคุมกฎของทั้งกลุ่มให้อยู่ — ในภาษา DDD เราเรียกก้อนที่คุมกฎของกลุ่มแบบนี้ว่า aggregate (เจาะลึกได้ที่คอร์ส DDD in Code)
หัวใจของบทนี้คือคำถามเดียว: เมื่อ Order เก็บ “list ของบรรทัด” ไว้ข้างใน เราจะเปิดให้โลกภายนอกเห็น list นั้นได้แค่ไหน ถ้าเปิดดิบๆ ใครก็เดินเข้ามาล้างตะกร้าทิ้งหรือยัดของแปลกๆ เข้าไปได้ กฎธุรกิจพังหมด เครื่องมือสามอย่างของ C# ที่แก้ปัญหานี้คือ read-only collection, backing field และ LINQLINQชุด extension method (`Where`, `Select`, `Sum`, `Any` ฯลฯ) ที่ทำให้ query collection ในหน่วยความจำได้ด้วยไวยากรณ์เดียว ไม่ต้องเขียน loop เอง เช่น หาผลรวมราคาทุกบรรทัดใน Order ด้วย `Lines.Sum(l => l.Total)`Architecture
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/
ทบทวนชิ้นส่วนที่จะเอามาประกอบ
หัวข้อที่มีชื่อว่า “ทบทวนชิ้นส่วนที่จะเอามาประกอบ”ก่อนอื่นขอทวนของเดิมจากบทก่อนๆ ที่ Order จะพึ่งพา — วางไว้ตรงนี้เพื่อให้ code ในบทนี้อ่านครบในตัวเอง:
namespace FoodOrdering.Domain.Orders;
// --- Value Object ที่ปั้นด้วย record (บทที่ 1) ---public record Money(decimal Amount, string Currency){ // guard: รักษากฎว่าจำนวนเงินติดลบไม่ได้ ตั้งแต่วินาทีที่สร้าง public decimal Amount { get; } = Amount >= 0 ? Amount : throw new ArgumentException("จำนวนเงินติดลบไม่ได้", nameof(Amount));
public static Money Thb(decimal amount) => new(amount, "THB");}
public record Quantity(int Value);public record OrderId(Guid Value);public record OrderLineId(Guid Value);public record ProductId(Guid Value);
// --- class ฐานของ "entity" — สิ่งที่มีตัวตน ระบุด้วย Id ---public abstract class Entity<TId>{ public TId Id { get; } protected Entity(TId id) => Id = id;}
// --- OrderLine "มีตัวตน" จึงเป็น class ไม่ใช่ record — หนึ่งบรรทัดในออเดอร์ ---public class OrderLine : Entity<OrderLineId>{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; }
// ราคารวมของบรรทัดนี้ = ราคาต่อหน่วย × จำนวน public Money Total => Money.Thb(UnitPrice.Amount * Quantity.Value);
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}จุดที่ต้องจำไว้คือ OrderLine มี property ชื่อ Total ที่คืน Money — เดี๋ยวเราจะเอาราคาของทุกบรรทัดมาบวกกันเป็นราคารวมของทั้งออเดอร์ ส่วน List<OrderLine> ที่จะเจอต่อจากนี้ วงเล็บมุม <OrderLine> คือ GenericsGenericsความสามารถเขียน type หรือ method ที่ทำงานกับ type ใดก็ได้โดยไม่ต้องรู้ล่วงหน้า ระบุด้วย type parameter เช่น `List<T>`, `IReadOnlyList<OrderLine>` — ทำให้เขียน code (เช่น Repository หรือ collection ห่อหุ้ม) ที่ type-safe และใช้ซ้ำได้กับหลายชนิดข้อมูลArchitecture — วิธีบอกว่า “list นี้บรรจุของชนิด OrderLine เท่านั้น” compiler จะกันไม่ให้เผลอยัดของชนิดอื่นเข้าไป
เปิด list ดิบๆ ออกไป — version ดิบ
หัวข้อที่มีชื่อว่า “เปิด list ดิบๆ ออกไป — version ดิบ”ทางที่ง่ายที่สุด (และผิดที่สุด) คือทำ field list เป็น public แล้วปล่อยให้ใครก็เข้าถึงตัวจริงได้ตรงๆ นี่คือ ❌ version ดิบ:
// ❌ version ดิบ: เปิด list ตัวจริงออกไปตรง ๆ ใครก็แก้ได้public class RawOrder{ public List<OrderLine> Lines = new(); // field สาธารณะ ชี้ไปที่ list ตัวจริง}List<OrderLine> มี method Add, Remove, Clear ครบ พอเราเปิด field นี้เป็น public code ที่ไหนก็ได้จึงเรียกมันได้หมด:
var order = new RawOrder();var someLine = new OrderLine(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(60));order.Lines.Add(someLine); // เพิ่มบรรทัดได้ ไม่ผ่านกฎอะไรเลยorder.Lines.Clear(); // 💥 ล้างตะกร้าทั้งใบได้ ทั้งที่ "ออเดอร์ห้ามว่าง"order.Lines = null; // เปลี่ยนทั้ง list เป็น null ได้ดื้อ ๆปัญหาไม่ใช่แค่ “ไม่สวย” แต่คือกฎธุรกิจของออเดอร์ถูกข้ามได้จากภายนอก — ใครก็ล้างตะกร้าจนว่างเปล่าได้ ทั้งที่ทั้งระบบตกลงกันว่าออเดอร์ต้องมีอย่างน้อยหนึ่งบรรทัดเสมอ เมื่อกฎของกลุ่มบังคับใช้ไม่ได้ ก้อน Order ก็ไม่ต่างอะไรกับถุงใส่ของที่เปิดฝาทิ้งไว้
backing field กับ read-only collection — เปิดแบบอ่านอย่างเดียว
หัวข้อที่มีชื่อว่า “backing field กับ read-only collection — เปิดแบบอ่านอย่างเดียว”ทางแก้คือแยก “list ตัวจริง” ออกจาก “หน้าต่างที่เปิดให้คนนอกดู” เก็บ list ตัวจริงไว้เป็น field ส่วนตัว แล้วเปิดออกไปเฉพาะมุมมองแบบอ่านอย่างเดียว
field ส่วนตัวที่เก็บข้อมูลจริงอยู่ข้างหลังแบบนี้เรียกว่า Backing FieldBacking Fieldfield ส่วนตัว (private) ที่เก็บข้อมูลจริงอยู่ข้างหลัง property หรือ collection ที่เปิดให้ภายนอกเห็นแบบอ่านอย่างเดียว เช่น `private readonly List<OrderLine> _lines` กับ property `IReadOnlyList<OrderLine> Lines` — วิธีที่ Aggregate รักษาไม่ให้ code ภายนอกมา Add/Remove ตรง ๆ โดยข้ามกฎธุรกิจTactical Design เขียนแบบนี้:
private readonly List<OrderLine> _lines;แกะทีละคำ:
private— เห็นได้เฉพาะภายใน classOrderเท่านั้น code ข้างนอกมองไม่เห็นเลยreadonly— ตัวแปร_linesจะถูกชี้ไปที่ list ก้อนไหน กำหนดได้ครั้งเดียวใน constructor แล้วเปลี่ยนให้ไปชี้ก้อนอื่นไม่ได้อีก (แต่เนื้อใน list ยัง Add/Remove จากใน class ได้)_lines— ชื่อขึ้นต้นด้วยขีดล่าง เป็นธรรมเนียมของ C# ที่บอกว่า “นี่คือ field ส่วนตัว”
ทีนี้เปิดหน้าต่างอ่านอย่างเดียวออกไป ด้วย type ตระกูล IReadOnly...:
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();IReadOnlyCollection<OrderLine> คือสัญญาว่า “ของกลุ่มนี้ นับจำนวนได้ (Count) และ วน loop ได้ (foreach) แต่ไม่มี Add, Remove, Clear ให้เรียก” — คนนอกจึงอ่านได้อย่างเดียว แก้ไม่ได้ ส่วน AsReadOnly() คือ method ของ List<T> ที่ห่อ list ตัวจริงไว้ในเปลือกอ่านอย่างเดียว โดยที่ยัง “มองเห็นของจริง” อยู่ตลอด (ถ้าภายใน class เพิ่มบรรทัด คนที่ถือ Lines อยู่ก็เห็นบรรทัดใหม่ด้วย)
ทั้งคู่เป็นมุมมองอ่านอย่างเดียว ต่างกันแค่ IReadOnlyList<T> เพิ่มการเข้าถึงด้วยเลขลำดับ (lines[0]) เข้ามาด้วย ถ้าคนนอกแค่ต้องวน loop กับนับจำนวน ใช้ IReadOnlyCollection<T> ก็พอ — เปิดสิทธิ์ให้น้อยที่สุดเท่าที่จำเป็น
LINQ — คำนวณและตรวจกลุ่มด้วยบรรทัดเดียว
หัวข้อที่มีชื่อว่า “LINQ — คำนวณและตรวจกลุ่มด้วยบรรทัดเดียว”พอมี list อยู่ในมือ งานที่ทำบ่อยที่สุดคือ “บวกทุกตัว”, “หาตัวที่เข้าเงื่อนไข”, “เช็กว่ามีตัวไหนไหม” ถ้าเขียน for loop เองทุกครั้งจะยืดยาวและพลาดง่าย LINQ คือชุด method (เรียกว่า extension method) ที่ต่อท้าย collection ได้เลย ทำงานพวกนี้จบในบรรทัดเดียว ห้าตัวที่ใช้บ่อยที่สุดคือ Where, Select, Sum, Any, All:
// สร้าง list ตัวอย่าง 2 บรรทัด: (2 × 60) + (1 × 120) = 240 บาทvar lines = new List<OrderLine>{ new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(60)), new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(1), Money.Thb(120)),};
// Where — กรองเฉพาะบรรทัดที่ราคาต่อหน่วยเกิน 100 บาท (คืน collection ใหม่)var pricey = lines.Where(l => l.UnitPrice.Amount > 100);
// Select — แปลงแต่ละบรรทัดเป็นค่าอื่น เช่น ดึงเฉพาะ ProductId ออกมาvar productIds = lines.Select(l => l.ProductId);
// Sum — บวกค่าจากทุกบรรทัด ได้ผลรวมเป็น decimal (ที่นี่ = 240)decimal total = lines.Sum(l => l.Total.Amount);
// Any — "มีอย่างน้อยหนึ่งตัวไหม" คืน true/false (ที่นี่ = true)bool hasAny = lines.Any();
// All — "ทุกตัวเข้าเงื่อนไขครบไหม" คืน true/false (ที่นี่ = true)bool allPositive = lines.All(l => l.Quantity.Value >= 1);สัญลักษณ์ l => ... คือ lambda — เขียน function สั้นๆ แบบไม่ต้องตั้งชื่อ อ่านว่า “รับบรรทัดหนึ่งตัวมาเรียกว่า l แล้วทำอะไรต่อ” เช่น l => l.Total.Amount แปลว่า “จากบรรทัด l เอาค่า Total.Amount ออกมา”
ตัวที่เราจะใช้จริงในบทนี้คือ Sum — เอาไปคำนวณราคารวมของทั้งออเดอร์ _lines.Sum(l => l.Total.Amount) แปลว่า “ไล่ทุกบรรทัดใน _lines ดึง Total.Amount ของแต่ละอันมาบวกกัน” ได้ผลรวมเป็น decimal แล้วเราห่อกลับเป็น Money ด้วย Money.Thb(...) จบในบรรทัดเดียว ไม่ต้องเขียนตัวแปรสะสมกับ loop เอง
ประกอบเป็น Order ตัวจริง
หัวข้อที่มีชื่อว่า “ประกอบเป็น Order ตัวจริง”รวมทุกอย่างเข้าด้วยกัน — backing field ส่วนตัว, หน้าต่างอ่านอย่างเดียว, Total ที่คำนวณสดด้วย LINQ และ constructor แบบ private ที่บังคับให้สร้างออเดอร์ผ่านทางเดียวคือ factory Place ซึ่งเป็นจุดที่เรารักษา invariant ① ด้วย Guard ClauseGuard Clauseเงื่อนไขตรวจสอบข้อมูลไว้ต้น ๆ ของ constructor หรือ method แล้วโยน exception ทันทีถ้าไม่ผ่าน เช่น `if (amount < 0) throw new ArgumentException(...)` — วิธีที่ Value Object และ Entity ใช้รักษา invariant ของตัวเองตั้งแต่วินาทีที่สร้าง ไม่ปล่อยให้อ็อบเจ็กต์ผิดรูปหลุดออกไปTactical Design:
namespace FoodOrdering.Domain.Orders;
public class Order : Entity<OrderId>{ // backing field: list ตัวจริง แก้ได้เฉพาะภายใน class นี้เท่านั้น private readonly List<OrderLine> _lines;
// หน้าต่างอ่านอย่างเดียวที่เปิดให้โลกภายนอก — เพิ่ม/ลบไม่ได้ public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
// Total คำนวณสดจาก LINQ ทุกครั้งที่ถูกเรียก ไม่มี field ให้หลุด sync public Money Total => Money.Thb(_lines.Sum(l => l.Total.Amount));
// ctor เป็น private — สร้าง Order ได้ทางเดียวคือผ่าน Place() private Order(OrderId id, List<OrderLine> lines) : base(id) { _lines = lines; }
// factory: ทางเข้าเดียวในการสร้างออเดอร์ พร้อมรักษา invariant ① public static Order Place(OrderId id, IReadOnlyList<OrderLine> lines) { // invariant ①: ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้ if (lines.Count == 0) throw new ArgumentException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้", nameof(lines));
// คัดลอกเข้า list ของตัวเองด้วย ToList() คนนอกจะแก้ list เดิมทีหลังไม่กระทบเรา return new Order(id, lines.ToList()); }}จุดที่ทำให้ก้อนนี้ “แน่น” มีสามอย่าง:
- backing field
_linesถูกซ่อน โลกภายนอกเห็นแค่Linesที่เป็นIReadOnlyCollection— อยากดูอยากวน loop ได้เต็มที่ แต่Add/Clearหายไปทั้งหมดOrder.Place(...).Lines.Clear()จะcompile ไม่ผ่าน ไม่ใช่แค่ห้ามตอนรัน Totalคำนวณสดด้วย LINQ ไม่ได้เก็บเป็นตัวเลขไว้ใน field จึงไม่มีทางที่Totalจะไม่ตรงกับบรรทัดจริง เพราะมันบวกจาก_linesใหม่ทุกครั้งที่ถูกอ่านPlaceเป็นด่านเดียวที่สร้างออเดอร์ได้ เพราะ constructor เป็นprivateทุกออเดอร์จึงผ่านการตรวจ invariant ① เสมอ ไม่มีทางหลุดออกมาเป็นออเดอร์ว่างๆ
ลองใช้งานจริง:
var order = Order.Place(new OrderId(Guid.NewGuid()), lines); // lines จากตัวอย่างก่อน (2 บรรทัด)
Money grand = order.Total; // Money.Thb(240)var view = order.Lines; // อ่านได้ วน loop ได้ นับ view.Count ได้// view.Add(someLine); // ❌ compile ไม่ผ่าน — IReadOnlyCollection ไม่มี Add// Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine>()); // 💥 ตะกร้าว่าง โยน exceptionflowchart TB
subgraph ORDER["Order (aggregate)"]
LINES["_lines : List ของ OrderLine<br/>private readonly — list ตัวจริง แก้ได้เฉพาะภายใน"]
RO["Lines : IReadOnlyCollection<br/>= _lines.AsReadOnly()<br/>หน้าต่างอ่านอย่างเดียว"]
TOTAL["Total : Money<br/>= Money.Thb(_lines.Sum(l -> l.Total.Amount))<br/>คำนวณสดด้วย LINQ"]
LINES -->|"AsReadOnly()"| RO
LINES -->|"Sum() ด้วย LINQ"| TOTAL
end
OUTSIDE["code ภายนอก"] -->|"อ่าน / foreach / นับ Count ได้"| RO
OUTSIDE -.->|"Add / Clear ไม่ได้"| LINES
คำบรรยายภาพ: _lines คือ backing field ส่วนตัว (list ตัวจริงที่แก้ได้เฉพาะภายใน Order) โลกภายนอกแตะมันตรงๆ ไม่ได้ — เส้นประที่วิ่งไป _lines คือทางที่ถูกปิด เข้าถึงได้เพียงผ่านสองหน้าต่างที่ Order เปิดให้เท่านั้น: Lines แบบอ่านอย่างเดียว และ Total ที่ LINQ บวกสดจาก _lines ทุกครั้ง ทั้งการเพิ่ม/ลบบรรทัดและกฎ “ตะกร้าห้ามว่าง” จึงถูกคุมไว้ใน class เดียว ไม่รั่วออกไปให้ใครข้าม
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”ตอนนี้ Order เก็บกลุ่มของบรรทัดไว้อย่างปลอดภัยแล้ว — เปิดอ่านได้ แต่แก้จากภายนอกไม่ได้ และคำนวณราคารวมเองด้วย LINQ บทต่อไปเราจะทำให้ domain คุยกับโลกภายนอกได้โดยไม่ผูกกับของจริง — interface กลายเป็น port, generics ทำ base class Entity<TId> (ที่เราสืบทอดมาตลอด) กับ repository ที่ใช้ซ้ำได้ และ dependency injection เดินสาย implementation จริงเข้ามาที่ขอบ
ส่วนแนวคิด aggregate ในเชิง DDD ว่าออกแบบขอบเขตของกลุ่มยังไง ทำไม Order ถึงเป็นเจ้าของ OrderLine และใครแก้ข้างในได้บ้าง เจาะลึกเต็มๆ ที่คอร์ส DDD in Code — คอร์สนี้โฟกัสที่ “ไวยากรณ์ C# ที่ทำให้กฎของกลุ่มบังคับใช้ได้จริง” ก่อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Exposing Collections (Antipattern) — ทำไมการเปิด
List<T>ดิบๆ ออกไปจึงเป็น antipattern และวิธีปิดด้วย backing field กับมุมมองอ่านอย่างเดียว - Aggregate — บทบาทของ aggregate ในการเป็นเจ้าของกลุ่มของอ็อบเจ็กต์และรักษา invariant ของทั้งกลุ่มไว้ในที่เดียว
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3ทำไม `Order` ถึงเปิด `Lines` ออกไปเป็น `IReadOnlyCollection<OrderLine>` แทนที่จะเป็น `List<OrderLine>`?