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 เขามักถามคำถามเหล่านี้เรียงตามความสำคัญ
- Business logic รันได้อิสระจากฐานข้อมูลหรือไม่ — นี่คือคำถามสำคัญที่สุด เพราะทีมที่มี rapid feedback cycle เร็ว (code ใหม่ → พิสูจน์ว่าใช้ได้ → ไปต่อ) มักมีประสิทธิภาพสูงกว่าทีมที่ business logic ผูกติดกับ infrastructure ที่รัน ถ้าสร้าง object domain ไม่ได้โดยไม่เชื่อมต่อฐานข้อมูลจริง การเขียน unit test ต่อกฎธุรกิจก็จะช้าและเปราะบางลงทันที
- ออกแบบ domain model แยกจาก database model ได้หรือไม่ — object model ควรออกแบบตามพฤติกรรมทางธุรกิจที่ต้องการ ส่วน schema ฐานข้อมูลควรออกแบบเพื่อประสิทธิภาพการอ่าน/เขียนและความถูกต้องของข้อมูล (referential integrity) ทั้งสองมีเป้าหมายต่างกัน การบังคับให้ object model แบนราบตาม schema ทุกตาราง (เช่น class
FlatTradeDetailที่มีทั้ง unit-of-measure และ amount แยกกัน4 field) มักทำให้ตรรกะทางธุรกิจที่ต้องเทียบหน่วยและแปลงค่าซับซ้อนขึ้นโดยไม่จำเป็น เมื่อเทียบกับการห่อพฤติกรรมไว้ใน value object เดียว - 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 ต้องเปิดเป็น
publicsetter หรือ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 ทุกครั้ง
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Separation of Concerns
- Dependency Inversion
- Encapsulation
- Repository
- Unit of Work
- Anemic Domain Model
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/principles/persistence-ignorance
- The Unit Of Work Pattern And Persistence Ignorance — Jeremy Miller, MSDN Magazine (Microsoft Learn)
- Plain old CLR object — Wikipedia
- Persistence Ignorance is Overrated — CodeOpinion
- Applying Domain-Driven Design and Patterns — Jimmy Nilsson (dddcommunity.org)