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

Data Class

class ที่​มี​แต่​ข้อมูล ไร้​พฤติกรรม​ที่​มี​ความหมาย

Data Class คือ class ที่ ถือ​ข้อมูล​แต่​ไร้​พฤติกรรม​ที่​มี​ความหมาย — มี​แต่ field (หรือ property) พร้อม getter/setter ยาวเหยียด ไม่มี operation ที่​ตรวจสอบ invariant ไม่มี method ที่​บังคับ​กฎ​ธุรกิจ ทุก​อย่าง​เปิด​ให้​ใคร​ก็ได้​อ่าน-เขียน​ตามใจ​ชอบ

Martin Fowler อธิบาย​ไว้​ใน Refactoring (ฉบับ​พิมพ์​ที่ 2) สั้น ๆ ว่า Data Class คือ class ที่​มี field (mutable state) แต่​ไม่มี​พฤติกรรม​อื่น​เลย อาจ​มี get/set สำหรับ field หรือ​เปิด field เป็น public ตรงๆ ก็ได้ — สัญญาณ​สำคัญ​คือ มี function ที่​อื่น​ใน​ระบบ​คอย​ดำเนิน​การ​กับ​ข้อมูล​ก้อน​นี้ ทั้ง​ที่​ตรรกะ​เหล่า​นั้น​ควร​จะ​ย้าย​เข้า​มา​อยู่​ใน class เอง

ไม่ใช่​ทุก class ที่​ถือ​ข้อมูล​จะ​เป็นกลิ่น​เสมอ​ไป — value object ที่ immutable, DTO ที่​ยืน​อยู่​ตรง​ขอบ​ระบบ (เช่น request/response model, JSON payload), หรือ record ที่​จงใจ​ออกแบบ​ให้​เป็น​ข้อมูล​ล้วนๆ (persistence entity แบบ ORM) ล้วน​ถูกต้อง​ตาม​บทบาท​ของ​มัน กลิ่น​นี้​เกิด​เมื่อ domain object ที่​ควร​มี​พฤติกรรม กลับ​กลาย​เป็น​ถุง​ใส่​ข้อมูล — นี่​คือ​รูปแบบ​เดียว​กับ​ที่​ระดับ​สถาปัตยกรรม​เรียก​ว่า Anemic Model นั่นเอง

  • class มี​แต่ field/property กับ getter/setter แทบ​ทุก​ตัว ไม่มี method ที่​ทำ​อะไร​มากกว่า​คืน​หรือ​กำหนด​ค่า
  • ไม่มี constructor ที่​ตรวจสอบ​ความ​ถูกต้อง (validation) ของ​ข้อมูล — สร้าง object ใน​สถานะ​ที่​ไม่​สม​เหตุ​สม​ผล​ได้​เสมอ
  • ทุก field เป็น public หรือ​มี setter สาธารณะ ไม่มี​การ​ปิด​กั้น (encapsulation) เลย
  • code ที่​เรียก​ใช้ class นี้​ต้อง​ดึง​ค่า​หลาย field ออก​มา​คำนวณ​เอง แล้ว​ค่อย​ยัด​ผลกลับ​เข้าไป​ใน field อื่น
  • ตรรกะ​ทาง​ธุรกิจ​แบบ​เดียวกัน​ถูก copy วาง​ซ้ำ​ใน​หลาย​ที่​ที่​ใช้ class นี้ (เพราะ​ไม่มี​ที่ “บ้าน” ให้​ตรรกะ​นั้น​อยู่)
  • เวลา​แก้​กฎ​ธุรกิจ​ที่​เกี่ยว​กับ​ข้อมูล​นี้ ต้อง​ไล่​แก้​หลาย file ที่​กระจาย​อยู่​ทั่ว codebase

จุด​แข็ง​ของ​การ​ออกแบบ​เชิง​วัตถุ (OOP) คือ​การ​รวม​ข้อมูล​กับ​พฤติกรรม​ที่​ทำงาน​กับ​ข้อมูล​นั้น​ไว้​ใน​ที่​เดียว Data Class ละทิ้ง​โอกาส​นี้​ไป​โดย​สิ้นเชิง ผล​ที่​ตาม​มา​มี​หลาย​ชั้น:

  1. ตรรกะ​กระจัดกระจาย — เมื่อ class ไม่มี​พฤติกรรม ใคร​ก็ตาม​ที่​ต้องการ​ทำ​อะไร​กับ​ข้อมูล​นั้น​ต้อง​เขียน logic เอง​ใน class ของ​ตัวเอง ทำให้​กฎ​ธุรกิจ​เดียวกัน​ถูก​เขียน​ซ้ำ​ใน​หลาย​จุด (ผิด​หลัก DRY)
  2. ดึงดูด Feature Envy — method ใน class อื่น​ที่​ต้อง “ล้วง” field ของ Data Class มา​คำนวณ คือ method ที่ “อิจฉา” ข้อมูล​ของ class อื่น มัน​ควร​อยู่​ใน class ที่​ข้อมูล​นั้น​อาศัย​อยู่​ต่างหาก
  3. ไม่มี invariant ที่​รับประกัน​ได้ — เมื่อ​ไม่มี constructor หรือ method ควบคุม​การ​เปลี่ยน​สถานะ ไม่มี​ใคร​รับประกัน​ได้​ว่า object จะ​อยู่​ใน​สถานะ​ที่ valid เสมอ (เช่น start date ที่มากกว่า end date, ยอด​เงิน​ติดลบ)
  4. แก้​กฎ​ธุรกิจ​ยาก และ​เสี่ยง​ต่อ bug — เพราะ​กฎ​เดียวกัน​กระจาย​อยู่​หลาย​ที่ พอ​แก้​จุด​หนึ่ง​แล้ว​ลืม​แก้​อีก​จุด ระบบ​ก็​จะ​พฤติกรรม​ไม่​สอดคล้อง​กัน (inconsistent)
  5. เชื่อม​โยง​กับ Anemic Model — เมื่อ Data Class เกิด​ขึ้น​ทั่ว​ทั้ง domain ระบบ​ทั้ง​ชุด​จะ​กลาย​เป็น anemic domain model ที่ domain layer ไม่มี​ความหมาย​อะไร​มากกว่าที่​เก็บ​ข้อมูล ส่วน business logic ทั้งหมด​ไป​กอง​อยู่​ที่ “service” หรือ “manager” ชั้น​บน — ขัด​กับ​หลักการ​พื้นฐาน​ของ OOP ที่​ว่า​ให้​รวม data กับ process ไว้​ด้วย​กัน

Invoice ด้าน​ล่าง​มี​แต่ field กับ getter/setter ส่วน​ตรรกะ​คำนวณ​ยอด​รวม-ส่วนลด-สถานะ​เกิน​กำหนด ถูก​เขียน​แยก​ไว้​ใน InvoiceService ซึ่ง​ต้อง​ดึง​ทุก field ออก​มา​คำนวณ​เอง (อาการ​ของ Feature Envy):

// Data Class: มีแต่ field กับ getter/setter ไม่มีพฤติกรรม
public class Invoice
{
public decimal SubTotal { get; set; }
public decimal DiscountPercent { get; set; }
public DateTime DueDate { get; set; }
public bool IsPaid { get; set; }
}
// ตรรกะทั้งหมดถูกยัดไว้นอก class
public class InvoiceService
{
public decimal CalculateTotal(Invoice invoice)
{
var discount = invoice.SubTotal * (invoice.DiscountPercent / 100m);
return invoice.SubTotal - discount;
}
public bool IsOverdue(Invoice invoice)
{
return !invoice.IsPaid && invoice.DueDate < DateTime.UtcNow;
}
}

ปัญหา: ไม่มี​อะไร​กัน​ไม่​ให้ DiscountPercent เป็น 150 หรือ SubTotal เป็น​ค่า​ติดลบ และ​ถ้า​มี​อีก​จุด​ใน​ระบบ​ต้อง​คำนวณ​ยอด​รวม​ใบ​แจ้ง​หนี้ ก็​มี​โอกาส​สูง​ที่​จะ​เขียน​สูตร​เดิม​ซ้ำ​อีกรอบ (หรือ​เขียน​ผิด​จาก​ต้นฉบับ)

ย้าย​พฤติกรรม​เข้าไป​อยู่​ใน class (Move Method / Extract Method ตาม​คำ​เรียก​ของ Fowler) พร้อม encapsulate field ผ่าน constructor ที่​ตรวจสอบ invariant และ​ปิด setter สาธารณะ (Remove Setting Method):

public class Invoice
{
public decimal SubTotal { get; }
public decimal DiscountPercent { get; }
public DateTime DueDate { get; }
public bool IsPaid { get; private set; }
public Invoice(decimal subTotal, decimal discountPercent, DateTime dueDate)
{
if (subTotal < 0)
throw new ArgumentException("SubTotal ต้องไม่ติดลบ");
if (discountPercent is < 0 or > 100)
throw new ArgumentException("DiscountPercent ต้องอยู่ระหว่าง 0-100");
SubTotal = subTotal;
DiscountPercent = discountPercent;
DueDate = dueDate;
}
// พฤติกรรมย้ายเข้ามาอยู่กับข้อมูลของมันเอง
public decimal CalculateTotal()
{
var discount = SubTotal * (DiscountPercent / 100m);
return SubTotal - discount;
}
public bool IsOverdue(DateTime asOf)
=> !IsPaid && DueDate < asOf;
public void MarkAsPaid() => IsPaid = true;
}

ตอน​นี้ Invoice รับประกัน​ได้​เอง​ว่า​ค่าที่​ถือ​อยู่​สม​เหตุ​สม​ผล​เสมอ (invariant ถูก​บังคับ​ใน constructor) และ​ไม่​ว่า​จะ​เรียก​ใช้​จาก​กี่​ที่ สูตร​คำนวณ​ยอด​รวม​ก็​มี​ที่มา​เดียว ไม่มี​ทาง​เขียน​ซ้ำ​ผิด​เพี้ยน​ได้​อีก code ฝั่ง​เรียก​ใช้​จาก “ถาม state แล้ว​คำนวณ​เอง” กลาย​เป็น “สั่ง​ให้ object ทำงาน​ให้” ตรง​ตาม​หลัก Tell, Don’t Ask

แผนภาพ​สรุป​ทิศทางการ​ย้าย​ความ​รับผิดชอบ:

flowchart LR
    Client[Client code]
    Before[Invoice data only]
    Service[InvoiceService with logic]
    After[Invoice with data plus behavior]

    Client --> Before
    Client --> Service
    Before -.refactor.-> After
    Service -.merge into.-> After

ไม่​ต้อง​รีบ​ยัด​พฤติกรรม​ทุก​อย่าง​เข้า class จน class บวม​กลาย​เป็น God Class อีก​กลิ่น​หนึ่ง — เป้าหมาย​คือ​ย้าย method ที่ “ควร​อยู่​ที่​นี่จริงๆ” (เพราะ​มัน​ทำงาน​กับ field ของ class นี้​เป็น​หลัก) ไม่ใช่​ทุก utility ที่​เคย​เรียก​ผ่าน​มัน​บังเอิญ​รับ parameter เป็น Invoice และ​สำหรับ DTO/entity ที่​ตั้งใจ​ให้​เป็น​ข้อมูล​ล้วนๆ ที่​ขอบ​ระบบ (serialization, ORM mapping) การ​เป็น Data Class ก็​ไม่ใช่​กลิ่น​แต่​อย่าง​ใด

  • Anemic Model — เมื่อ Data Class เกิด​กับ domain object ทั่ว​ทั้ง​ระบบ
  • Feature Envy — อาการ​คู่​กัน​ที่ method ภายนอก​คอย​ล้วง​ข้อมูล​ของ Data Class
  • Primitive Obsession — Data Class มัก​ถือ field เป็น primitive ล้วน​แทนที่​จะ​ห่อ​เป็น value object
  • Duplicate Code — ผลพลอยได้​จาก​ตรรกะ​ที่​ต้อง​เขียน​ซ้ำ​เพราะ​ไม่มี​บ้าน​ให้​อยู่
  • Tell, Don’t Ask — หลักการ​ที่​ตรง​ข้าม​กับ​พฤติกรรม​ของ Data Class
  • Encapsulation — หลักการ​พื้นฐาน​ที่ Data Class ละเมิด