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

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; } พิมพ์​บรรทัด​เดียว​จบ ไม่​ต้อง​เขียน method AddItem / 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 เพื่อ​โฟกัส​เรื่อง​อื่น ทำให้​ผู้​เรียน​ติด​นิสัย​นี้​ไป​โดย​ไม่รู้ตัว
  1. Invariant ของ aggregate พัง​ได้​ง่าย — ถ้า Order ห้าม​เพิ่ม item หลัง​จาก​สถานะ​เป็น Shipped แต่ collection เปิด​เป็น public การ​เช็ค​เงื่อนไข​นี้​ทำ​ไม่​ได้​เลย เพราะ caller ไข diff เข้าไป​ตรง ๆ โดย​ไม่​ผ่าน method ใด ๆ ของ Order
  2. Side effect ที่​ควร​เกิด​พร้อม​กัน​หาย​ไป — สมมติ​ทุก​ครั้ง​ที่​เพิ่ม item ต้อง​อัปเดต TotalPrice หรือ raise domain event ด้วย ถ้า​แก้ collection ตรง ๆ side effect เหล่า​นี้​จะ​ไม่​ทำงาน ทำให้ state ไม่ sync กัน
  3. นำ​ไป​สู่ Anemic Domain Model — เมื่อ collection เปิดเผย​หมด ตรรกะ​ทาง​ธุรกิจ​ก็​ไหล​ออก​ไป​อยู่​ใน service หรือ controller ภายนอก​แทนที่​จะ​อยู่​ใน entity ทำให้ entity กลาย​เป็น​แค่​ที่​เก็บ​ข้อมูล ไม่มี​พฤติกรรม
  4. เปิด​ช่อง​ให้​ทำลาย state ทั้ง​ก้อน​โดย​ไม่​ตั้งใจorder.Items = null หรือ order.Items.Clear() ทำให้ object กลับ​ไป​อยู่​ใน​สถานะ​ที่​ไม่​สม​เหตุ​สม​ผล (invalid state) ได้​ทันที โดย​ไม่มี validation ใด ๆ คั่น​กลาง
  5. ขัด​กับ​หลัก 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 client
var order = repository.GetById(orderId);
order.Items.Add(new OrderItem(sku, qty)); // เพิ่มได้แม้ order จะ Shipped ไปแล้ว
order.Items.Clear(); // ล้างข้อมูลทั้งหมดโดยไม่มีใครรู้
order.Items = null; // พัง NullReferenceException ที่อื่นแทน

ปัญหา​คือ​ไม่มี​จุด​ใด​ใน Order ที่​จะ​เช็ค​ได้​เลย​ว่า​กำลัง​จะ​แก้ collection การ validate ทั้งหมด​ต้อง​กระจาย​ไป​อยู่​ใน caller ทุก​ที่​ที่​เรียก​ใช้ — ซึ่ง​ใน​ทาง​ปฏิบัติ​มัก​ลืม​เช็ค​สัก​จุด​ใด​จุด​หนึ่ง​เสมอ

ใช้​เทคนิค 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 เหมือน​เดิม