Exposing Collection Properties
เปิด collection ของ entity ออกมาตรง ๆ จนพัง encapsulation
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”Exposing Collection Properties คือการที่ class ประกาศ field ที่เป็น collection เช่น List<T> แล้วเปิดออกมาให้ภายนอกเข้าถึงตรง ๆ ผ่าน public property หรือ public getter ที่คืนค่า reference ของ collection นั้นทั้งก้อน
public class Order{ // antipattern: property นี้คืน reference ของ List ตัวจริงออกไป public List<OrderItem> Items { get; set; } = new();}เมื่อ caller เขียน order.Items.Add(item) หรือแม้แต่ order.Items.Clear() และ order.Items = null code ภายนอกจะแก้ไขสถานะภายในของ Order ได้โดยตรง โดยที่ Order ไม่มีโอกาสรับรู้หรือควบคุมอะไรเลย นี่คือรูปแบบเฉพาะของการละเมิด encapsulation ที่เกิดกับ collection field โดยเฉพาะ ต่างจาก property ทั่วไปตรงที่ตัว object เองยังเปิดหน้าตาเป็น “มี property” แต่ตัว collection ที่อยู่ข้างในนั้น mutable ได้จากภายนอกทั้งหมด ทั้งที่ตัว object ควรเป็นเจ้าของและผู้ควบคุมเนื้อหาของ collection แต่เพียงผู้เดียว
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”- เขียนเร็ว — auto-property แบบ
public List<T> Items { get; set; }พิมพ์บรรทัดเดียวจบ ไม่ต้องเขียน methodAddItem/RemoveItemเพิ่ม - ORM ชอบ — เครื่องมือ mapping และ scaffolding จำนวนมาก generate collection navigation property แบบ public setter ให้อัตโนมัติ ทำให้ดูเหมือนเป็น “มาตรฐาน” ของ entity
- เข้ากับ LINQ ได้ทันที — เขียน
order.Items.Where(...),order.Items.Sum(...)ได้โดยไม่ต้อง wrap อะไรเพิ่ม รู้สึกสะดวกในระยะสั้น - ดู “simple” ตาม textbook ตัวอย่างเบื้องต้น — code สอนพื้นฐานจำนวนมากใช้ public collection property เพื่อโฟกัสเรื่องอื่น ทำให้ผู้เรียนติดนิสัยนี้ไปโดยไม่รู้ตัว
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- Invariant ของ aggregate พังได้ง่าย — ถ้า
Orderห้ามเพิ่ม item หลังจากสถานะเป็นShippedแต่ collection เปิดเป็น public การเช็คเงื่อนไขนี้ทำไม่ได้เลย เพราะ caller ไข diff เข้าไปตรง ๆ โดยไม่ผ่าน method ใด ๆ ของOrder - Side effect ที่ควรเกิดพร้อมกันหายไป — สมมติทุกครั้งที่เพิ่ม item ต้องอัปเดต
TotalPriceหรือ raise domain event ด้วย ถ้าแก้ collection ตรง ๆ side effect เหล่านี้จะไม่ทำงาน ทำให้ state ไม่ sync กัน - นำไปสู่ Anemic Domain Model — เมื่อ collection เปิดเผยหมด ตรรกะทางธุรกิจก็ไหลออกไปอยู่ใน service หรือ controller ภายนอกแทนที่จะอยู่ใน entity ทำให้ entity กลายเป็นแค่ที่เก็บข้อมูล ไม่มีพฤติกรรม
- เปิดช่องให้ทำลาย state ทั้งก้อนโดยไม่ตั้งใจ —
order.Items = nullหรือorder.Items.Clear()ทำให้ object กลับไปอยู่ในสถานะที่ไม่สมเหตุสมผล (invalid state) ได้ทันที โดยไม่มี validation ใด ๆ คั่นกลาง - ขัดกับหลัก Tell, Don’t Ask — แทนที่ caller จะ “บอก” object ว่าต้องการทำอะไร (เช่น “เพิ่มสินค้านี้ให้หน่อย”) caller กลับต้อง “ถาม” เอา collection ออกมาแล้วไปจัดการเองข้างนอก ซึ่งทำให้ตรรกะกระจัดกระจายและทดสอบยากขึ้น
flowchart LR
Client1[Client Code] --> ItemsBad[Items List Exposed Directly]
ItemsBad --> Corrupt[Corrupt or Inconsistent State]
Client2[Client Code] --> AddItem[AddItem Method Called]
AddItem --> CheckRule[Invariant Checked Inside Order]
CheckRule --> ValidState[Consistent State]
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”แบบ antipattern — collection ถูกเปิดเป็น public พร้อม setter ทำให้ invariant ของ order (ห้ามแก้ไขหลังส่งของแล้ว) ไม่ถูกบังคับใช้เลย
public class Order{ public OrderStatus Status { get; set; } public List<OrderItem> Items { get; set; } = new();}
// ที่ไหนสักแห่งใน code clientvar order = repository.GetById(orderId);order.Items.Add(new OrderItem(sku, qty)); // เพิ่มได้แม้ order จะ Shipped ไปแล้วorder.Items.Clear(); // ล้างข้อมูลทั้งหมดโดยไม่มีใครรู้order.Items = null; // พัง NullReferenceException ที่อื่นแทนปัญหาคือไม่มีจุดใดใน Order ที่จะเช็คได้เลยว่ากำลังจะแก้ collection การ validate ทั้งหมดต้องกระจายไปอยู่ใน caller ทุกที่ที่เรียกใช้ — ซึ่งในทางปฏิบัติมักลืมเช็คสักจุดใดจุดหนึ่งเสมอ
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”ใช้เทคนิค Encapsulate Collection: เก็บ collection ไว้เป็น private field, เปิด getter แบบอ่านอย่างเดียวผ่าน IReadOnlyCollection<T> (หรือคืนสำเนา) และเพิ่ม method เฉพาะสำหรับแก้ไข เพื่อให้ทุกการเปลี่ยนแปลงผ่านจุดเดียวที่บังคับ invariant ได้
public class Order{ private readonly List<OrderItem> _items = new();
public OrderStatus Status { get; private set; }
// เปิดออกมาเป็น read-only เท่านั้น ห้ามแก้ผ่านตรงนี้ public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public void AddItem(OrderItem item) { if (Status == OrderStatus.Shipped) { throw new InvalidOperationException("ไม่สามารถเพิ่มสินค้าหลังจากส่งของแล้ว"); }
_items.Add(item); RecalculateTotal(); // side effect ที่เกี่ยวข้องถูกรับประกันว่าเกิดพร้อมกันเสมอ }
public void RemoveItem(OrderItem item) { if (Status == OrderStatus.Shipped) { throw new InvalidOperationException("ไม่สามารถลบสินค้าหลังจากส่งของแล้ว"); }
_items.Remove(item); RecalculateTotal(); }
private void RecalculateTotal() { /* ... */ }}ตอนนี้ caller ทำได้แค่ order.AddItem(item) เท่านั้น จะ order.Items.Add(...) ตรง ๆ ไม่ได้อีกต่อไปเพราะ compiler เห็น Items เป็น IReadOnlyCollection<T> ที่ไม่มี method Add และการเช็ค Status == Shipped ก็อยู่รวมศูนย์ในที่เดียว ไม่กระจัดกระจายไปตามจุดเรียกใช้
ข้อควรระวังเรื่อง ORM: บาง ORM (เช่น EF Core รุ่นเก่า) ต้องการ setter หรือ backing field ที่เข้าถึงได้เพื่อ materialize collection ตอน query โดยทั่วไปแก้ได้ด้วยการตั้งชื่อ backing field ให้ ORM หาเจอ (convention-based mapping) หรือใช้ private constructor/setter เฉพาะให้ ORM ใช้ โดยที่ domain logic ยังคงบังคับผ่าน public method เหมือนเดิม