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

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 ตัวอย่าง (กำลัง​จัด​ทำ)

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 จะ​กัน​ไม่​ให้​เผลอ​ยัด​ของ​ชนิด​อื่น​เข้าไป

ทาง​ที่​ง่าย​ที่สุด (และ​ผิด​ที่สุด) คือ​ทำ 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 ก็​ไม่​ต่าง​อะไร​กับ​ถุง​ใส่​ของ​ที่​เปิด​ฝา​ทิ้ง​ไว้

ทาง​แก้​คือ​แยก “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 — เห็น​ได้​เฉพาะ​ภายใน class Order เท่านั้น 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 อยู่​ก็​เห็น​บรรทัด​ใหม่​ด้วย)

IReadOnlyCollection ต่าง​กับ IReadOnlyList ตรง​ไหน

ทั้ง​คู่​เป็น​มุมมอง​อ่าน​อย่าง​เดียว ต่าง​กัน​แค่ IReadOnlyList<T> เพิ่ม​การ​เข้าถึง​ด้วย​เลขลำดับ (lines[0]) เข้า​มา​ด้วย ถ้า​คนนอก​แค่​ต้อง​วน loop กับ​นับ​จำนวน ใช้ IReadOnlyCollection<T> ก็พอ — เปิด​สิทธิ์​ให้​น้อย​ที่สุด​เท่า​ที่​จำเป็น

พอมี 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 เอง

รวม​ทุก​อย่าง​เข้า​ด้วย​กัน — 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>()); // 💥 ตะกร้าว่าง โยน exception
flowchart 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

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

  • Exposing Collections (Antipattern) — ทำไม​การ​เปิด List<T> ดิบๆ ออก​ไป​จึง​เป็น antipattern และ​วิธี​ปิด​ด้วย backing field กับ​มุมมอง​อ่าน​อย่าง​เดียว
  • Aggregate — บทบาท​ของ aggregate ใน​การ​เป็น​เจ้าของ​กลุ่ม​ของ​อ็อบเจ็กต์และ​รักษา invariant ของ​ทั้ง​กลุ่ม​ไว้​ใน​ที่​เดียว

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

ข้อ 1 / 3

ทำไม `Order` ถึงเปิด `Lines` ออกไปเป็น `IReadOnlyCollection<OrderLine>` แทนที่จะเป็น `List<OrderLine>`?