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

Persistence Ignorance

model domain ไม่​ควร​รู้​ว่า​ตัวเอง​ถูก​บันทึก​อย่างไร

หลักการ Persistence Ignorance (PI) ถือว่า class ที่​จำลอง domain ทาง​ธุรกิจ​ใน application ไม่​ควร​ได้​รับ​ผลกระทบ​จาก​วิธี​ที่​มัน​อาจ​ถูก​บันทึก (persisted) ดังนั้น การ​ออกแบบ​ของ class เหล่า​นั้น​ควร​สะท้อน​การ​ออกแบบ​ใน​อุดมคติ​ที่​จำเป็นต่อ​การ​แก้​ปัญหา​ทาง​ธุรกิจ​ให้​มาก​ที่สุด และ​ไม่​ควร​ถูก​ปน​เปื้อน​ด้วย​เรื่อง​ที่​เกี่ยว​กับ​การ​บันทึก​และ​ดึงสถานะ​ของ object กลับ​มา

Jimmy Nilsson เป็น​ผู้​ที่​ทำให้​คำ​ว่า persistence ignorant (PI) เป็น​ที่​รู้จัก​ใน​วง​กว้าง ใน​หนังสือ Applying Domain-Driven Design and Patterns (2006) เขา​นิยาม POCO (Plain Old CLR Object) ว่า​คือ “class ธรรมดา ๆ ที่​โฟกัส​อยู่​กับ​ปัญหา​ทาง​ธุรกิจ​ตรง​หน้า โดย​ไม่​เติม​สิ่ง​ใด​เข้าไป​ด้วย​เหตุผล​ด้าน infrastructure … ไม่​ควร​มี​อะไร​อื่น​อยู่​ใน class ของ Domain Model นอกจาก​สิ่ง​ที่​เกี่ยว​กับ​ปัญหา​ทาง​ธุรกิจ” คำ​ว่า POCO เอง​ก็​เล่นคำ​มา​จาก POJO (Plain Old Java Object) ที่ Martin Fowler, Rebecca Parsons และ Josh MacKenzie บัญญัติ​ขึ้น​ใน​ปี 2000 เพื่อ​ล้อเลียน​ความ​ซับซ้อน​เกิน​จำเป็น​ของ EJB ยุค​แรก

การ​ละเมิด Persistence Ignorance ที่​พบ​บ่อย​ได้แก่ object domain ที่​ต้อง​สืบทอด​จาก class ฐาน​เฉพาะ​ของ framework หรือ​ต้อง​เปิดเผย property บาง​อย่าง​เป็น public/virtual โดย​ไม่มี​เหตุผล​ทาง​ธุรกิจ บาง​ครั้ง​ความ​รู้เรื่อง persistence อยู่​ใน​รูป attribute ที่​ต้อง​แปะ​บน class หรือ​การ​รองรับ​เฉพาะ collection บาง​ชนิด​หรือ​ระดับ​การ​มอง​เห็น​ของ property บาง​แบบ

PI ไม่ใช่​เรื่อง​ขาว-ดำ แต่​เป็น spec ตรัม (spectrum) มี​หลาย​ระดับ โดย​ระดับ​สูงสุด​ถูก​อธิบาย​ว่า​เป็น Plain Old CLR Objects (POCOs) ใน​โลก .NET และ Plain Old Java Objects (POJOs) ใน​โลก Java — คือ class ธรรมดา​ที่​ไม่​ผูก​กับ​กลไก persistence ใด ๆ เลย ไม่มี base class บังคับ ไม่มี attribute ของ ORM และ​ไม่มี​ข้อ​จำกัด​เรื่อง constructor หรือ property ใน​ทาง​ปฏิบัติ แทบ​ไม่มี​ระบบ​ใด​ทำ PI ได้​สมบูรณ์ 100% เพราะ​เครื่องมือ persistence ส่วน​ใหญ่​ยัง​คง​เรียกร้อง​อะไร​บาง​อย่าง​กลับ​มา เช่น constructor ที่​ไม่มี parameter หรือ property ที่​เป็น virtual เพื่อ​รองรับ lazy loading — สิ่ง​สำคัญ​คือ​การ​ลด​ผลกระทบ​นั้น​ให้​เหลือ​น้อย​ที่สุด ไม่ใช่​การ​ไล่​ตาม​ความ​สมบูรณ์​แบบ​จน​ออกแบบ domain ไม่​ได้

Jeremy Miller อธิบาย​ไว้​ใน MSDN Magazine (2009) ว่า​เมื่อ​ประเมิน​เครื่องมือ persistence เขา​มัก​ถาม​คำถาม​เหล่า​นี้​เรียง​ตาม​ความ​สำคัญ

  1. Business logic รัน​ได้​อิสระ​จาก​ฐาน​ข้อมูล​หรือ​ไม่ — นี่​คือ​คำถาม​สำคัญ​ที่สุด เพราะ​ทีม​ที่​มี rapid feedback cycle เร็ว (code ใหม่ → พิสูจน์​ว่า​ใช้ได้ → ไป​ต่อ) มัก​มี​ประสิทธิภาพ​สูง​กว่า​ทีม​ที่ business logic ผูก​ติด​กับ infrastructure ที่​รัน ถ้า​สร้าง object domain ไม่​ได้​โดย​ไม่​เชื่อม​ต่อ​ฐาน​ข้อมูล​จริง การ​เขียน unit test ต่อ​กฎ​ธุรกิจ​ก็​จะ​ช้า​และ​เปราะบาง​ลง​ทันที
  2. ออกแบบ domain model แยก​จาก database model ได้​หรือ​ไม่ — object model ควร​ออกแบบ​ตาม​พฤติกรรม​ทาง​ธุรกิจ​ที่​ต้องการ ส่วน schema ฐาน​ข้อมูล​ควร​ออกแบบ​เพื่อ​ประสิทธิภาพ​การ​อ่าน/เขียน​และ​ความ​ถูกต้อง​ของ​ข้อมูล (referential integrity) ทั้ง​สอง​มี​เป้าหมาย​ต่าง​กัน การ​บังคับ​ให้ object model แบน​ราบ​ตาม schema ทุก​ตาราง (เช่น class FlatTradeDetail ที่​มี​ทั้ง unit-of-measure และ amount แยก​กัน4 field) มัก​ทำให้​ตรรกะ​ทาง​ธุรกิจ​ที่​ต้อง​เทียบ​หน่วย​และ​แปลง​ค่า​ซับซ้อน​ขึ้น​โดย​ไม่​จำเป็น เมื่อ​เทียบ​กับ​การ​ห่อ​พฤติกรรม​ไว้​ใน value object เดียว
  3. persistence strategy ที่​เลือก​ส่ง​ผล​ต่อ business logic แค่​ไหน — ไม่มี​เครื่องมือ​ใด​สมบูรณ์​แบบ ทุก​ตัว​ส่ง​ผล​ต่อ​รูปร่าง​ของ class domain ไม่​มาก​ก็​น้อย เป้าหมาย​ของ PI คือ​ลด​ผลกระทบ​นั้น​ให้​เหลือ​น้อย​ที่สุด ไม่ใช่​กำจัด​ให้​หมดจด

ประโยชน์​ที่​ตาม​มา​คือ ความ​สามารถ​ใน​การ​ทดสอบ (testability) สูง​ขึ้น​มาก เพราะ​สร้าง object ใน​หน่วย​ความ​จำ​ได้​ทันที​โดย​ไม่​ต้อง​เซ็ตอัป​ฐาน​ข้อมูล ทดสอบ​เร็ว​ขึ้น​เป็น​สิบ​เท่า และ​ยัง​ทำให้​สลับ​เปลี่ยน​เทคโนโลยี persistence ใน​อนาคต​ได้​ง่าย​ขึ้น เพราะ domain ไม่​ได้​ผูก​ติด​กับ​มัน​ตั้งแต่​แรก

อย่างไร​ก็ตาม PI ก็​มี​ข้อ​ถกเถียง บทความ “Persistence Ignorance is Overrated” ของ CodeOpinion ชี้​ว่าการ​ไล่​ตาม PI แบบ​สมบูรณ์​มัก​ต้อง​แลก​กับ​ความ​ซับซ้อน​ของ mapping layer ที่​เพิ่ม​ขึ้น และ​ใน​ทาง​ปฏิบัติ ORM อย่าง Entity Framework มัก​บังคับ​ให้ domain entity มี​ข้อมูล​เชิง​โครงสร้าง (เช่น primary key, audit field) ปน​อยู่​บ้าง คำ​แนะนำ​คือ​ให้​มอง​เป็น spec ต​รัม​ระหว่าง pragmatism (ยอมรับ infrastructure concern บาง​ส่วน) กับ purity (แยก data model ออก​จาก domain model โดย​สิ้นเชิง) แล้ว​เลือก​จุด​ที่​เหมาะ​กับ​ความ​ซับซ้อน​ของ domain จริง ๆ ไม่ใช่​ทุก​ระบบ​ต้องการ PI ระดับ​สูงสุด — ระบบ​ที่​ตรรกะ​ทาง​ธุรกิจ​เรียบ​ง่าย​อาจ​ใช้​สถาปัตยกรรม​แบบ Active Record ที่ generate class ตรง​จาก schema ได้​โดย​ไม่มี​ปัญหา

ก่อน (ละเมิด PI)Invoice ผูก​ติด​กับ Entity Framework โดยตรง ทั้ง base class, attribute, constructor ว่าง และ setter สาธารณะ​ที่​เปิด​ให้​ทุก​ที่​แก้ไข​สถานะ​ได้ แม้​จะ​ขัด​กฎ​ธุรกิจ

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
// ละเมิด PI: domain รู้จักกลไก ORM โดยตรง
[Table("Invoices")]
public class Invoice : EntityBase // ต้องสืบทอดจาก class ฐานของ ORM
{
[Key]
public int Id { get; set; }
[Column("invoice_date")]
public DateTime InvoiceDate { get; set; }
// ต้องเป็น virtual เพื่อให้ ORM สร้าง dynamic proxy สำหรับ lazy loading
public virtual Customer Customer { get; set; }
// setter สาธารณะ ทำให้ปิดใบแจ้งหนี้ที่ปิดไปแล้วซ้ำได้โดยไม่มีการตรวจสอบ
public bool IsOpen { get; set; }
// ORM ต้องการ constructor ว่าง จึงสร้าง Invoice ที่ไม่มี Customer ได้
public Invoice() { }
}

หลัง (ยึด PI)Invoice เป็น POCO ล้วน ๆ ไม่รู้จัก ORM ใด ๆ กฎ​ธุรกิจ​ถูก​บังคับ​ผ่าน constructor และ method ส่วน​การ mapping ไป​ยัง​ตาราง​ถูก​ย้าย​ออก​ไป​อยู่​ที่ infrastructure layer ทั้งหมด (เช่น Fluent API configuration) โดย​มี IInvoiceRepository เป็น​สัญญา​ที่ domain กำหนด แต่ infrastructure เป็น​ผู้ implement — สอดคล้อง​กับ Dependency Inversion

// PI: Invoice ไม่รู้จักกลไก persistence ใด ๆ เลย
public class Invoice
{
private readonly List<InvoiceLine> _lines = new();
public Invoice(CustomerId customerId, DateTime invoiceDate)
{
CustomerId = customerId ?? throw new ArgumentNullException(nameof(customerId));
InvoiceDate = invoiceDate;
IsOpen = true;
}
public CustomerId CustomerId { get; }
public DateTime InvoiceDate { get; }
public bool IsOpen { get; private set; }
public IReadOnlyList<InvoiceLine> Lines => _lines.AsReadOnly();
public void Close()
{
if (!IsOpen)
throw new InvalidOperationException("ปิดใบแจ้งหนี้ที่ปิดไปแล้วซ้ำไม่ได้");
IsOpen = false;
}
}
// สัญญานี้ประกาศอยู่ใน domain แต่ implementation (Entity Framework, Dapper, ฯลฯ)
// ถูกเขียนแยกไว้ที่ infrastructure layer เท่านั้น
public interface IInvoiceRepository
{
Invoice? FindById(InvoiceId id);
void Add(Invoice invoice);
}

ทิศทาง​ของ​การ​พึ่งพา​ใน​ตัวอย่าง​นี้​สรุป​ได้​ดัง​แผนภาพ domain กำหนด interface ของ repository เอง ส่วน ORM หรือ data mapper เป็น​ผู้ implement มัน​จาก​ภายนอก — domain จึง​ไม่มี​ทาง​รู้​ว่า​ใคร​เป็น​ผู้​บันทึก​สถานะ​ของ​มัน

flowchart LR
    Domain[Domain Model Invoice] -->|depends on| RepoPort[Repository Interface]
    ORM[ORM or Data Mapper] -.implements.-> RepoPort
    ORM --> DB[(Database)]
  • class domain ต้อง​สืบทอด​จาก class ฐาน​เฉพาะ​ของ persistence framework (เช่น EntityBase, ActiveRecordBase)
  • ต้อง​มี public parameterless constructor เพียง​เพื่อ​ให้ ORM สร้าง instance ได้ ทั้ง​ที่​ทำให้​สร้าง object ใน​สถานะ​ที่​ไม่​ครบ invariant ได้
  • Property ต้อง​เปิด​เป็น public setter หรือ virtual โดย​ไม่มี​เหตุผล​ทาง​ธุรกิจ เพียง​เพื่อ​รองรับ change tracking หรือ lazy-loading proxy
  • มี attribute ของ persistence framework (เช่น [Table], [Column], [Key]) ปะปน​อยู่​ใน class domain โดยตรง
  • object model ถูก​บังคับ​ให้​แบน​ราบ (flat) ตรง​กับ schema ฐาน​ข้อมูล​แบบ 1:1 แม้​จะ​ขัด​กับ​พฤติกรรม​ทาง​ธุรกิจ​ที่​ควร​จำลอง (เช่น​แยก field unit-of-measure กับ amount แทนที่​จะ​ห่อ​เป็น value object เดียว)
  • เขียน unit test ให้ business rule ไม่​ได้​เลย เว้น​แต่​ต้อง​เชื่อม​ต่อ​ฐาน​ข้อมูล​จริง​ก่อน​เสมอ
  • เปลี่ยน​เครื่องมือ persistence ทีไร ต้อง​แก้​โครงสร้าง class domain ทุก​ครั้ง