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

ทำไม persistence ยาก​สำหรับ model domain ที่​ดี

ในบท​ที่ 4 ของ​คอร์ส Clean Architecture .NET เรา​ได้​ชิมลาง​การ​เก็บ Order ลง​ฐาน​ข้อมูล​ไป​แล้ว​รอบ​หนึ่ง — HasConversion แปลง strongly-typed ID, OwnsOne ฝัง Money เป็น column, HasMany ผูก _lines เข้า​กับ backing field โดยที่ Order ไม่​ต้อง​แก้​แม้แต่​บรรทัด​เดียว นั่น​คือ​ที​เซอร์ คอร์ส​นี้​คือ​ภาค​ที่​ลง​ลึก​จริง: เก็บ Order aggregate ทั้ง​ก้อน ลง PostgreSQL อย่าง​ซื่อสัตย์​ต่อ model ตั้งแต่ owned type, migration, optimistic concurrency ไป​จนถึง query ที่​ไม่ N+1 — โดย​ไม่​ยอม​ให้ ORM บังคับ​ให้ model กลับ​ไป​กลวง

บท​แรก​นี้​ยัง​ไม่​เขียน mapping สัก​บรรทัด แต่​ตอบ​คำถาม​ที่​อยู่​เบื้องหลัง​ทั้ง​คอร์ส​ก่อน: ทำไม​การ​เก็บ model ที่​ดี​ลง SQL ถึง​ไม่​ตรง​ไป​ตรง​มา ถ้า​ไม่​เข้าใจ​ว่า​ปัญหา​อยู่​ตรง​ไหน เครื่องมือ​ของ EF Core ในบทถัดๆ ไป​ก็​จะ​ดูเหมือน​พิธีกรรม​ท่องจำ ไม่ใช่​คำ​ตอบ​ของ​ปัญหา​จริง

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เป็น​ภาพ​รวม​เชิง​แนวคิด code mapping จริง​เริ่ม​บท​ที่ 2 ที่ path FoodOrdering.Infrastructure/Persistence/

model Order ที่​คอร์ส​ก่อนหน้า​ปั้น​ไว้​ไม่ใช่​ถุง​ข้อมูล — มัน​เป็น วัตถุ​ที่​รวย behavior: มี Place(...) เป็น​ประตู​เดียว​ที่​สร้าง​ออเดอร์​ได้, มี _lines เป็น collection ส่วนตัว​ที่​ห้าม​ใคร​นอก​กลุ่ม​แตะ, มี Money เป็น value object ที่​ไม่มี identity ของ​ตัวเอง และ​มี invariant ที่ root คอยรักษาอยู่​ตลอด (ตะกร้า​ห้าม​ว่าง, Total ต้อง​เท่า​ผล​รวม​ทุก​บรรทัด​เสมอ) ส่วน​ฐาน​ข้อมูล​เชิง​สัมพันธ์​รู้จัก​แค่​สิ่ง​เดียว: ตาราง​แบนๆ ที่​มี​แถว​กับ column ไม่มี behavior ไม่มี collection ส่วนตัว ไม่มี​ความคิด​เรื่อง “ประตู​เดียว”

ความ​ไม่​เข้า​กัน​โดย​ธรรมชาติ​นี้​มีชื่อ​เรียก​ว่า Object-Relational Impedance MismatchObject-Relational Impedance Mismatchความ​ไม่​เข้า​กัน​โดย​ธรรมชาติ​ระหว่าง model เชิง​วัตถุ (Aggregate ที่​ซ้อน​กัน, Value Object, collection ส่วนตัว) กับ model เชิง​สัมพันธ์ (ตาราง/แถว/column แบน) — สาเหตุ​ที่​การ map Order ทั้ง​ก้อน​ลง SQL ไม่​ตรง​ไป​ตรง​มาArchitecture — มัน​คือ​สาเหตุ​ที่​การ map Order ทั้ง​ก้อน​ลง SQL ไม่ใช่​การก็อปปี้ field ไป​เป็น column ตรงๆ วัตถุ​หนึ่ง​ตัว​อาจ​กระจาย​เป็น​หลาย​ตาราง (Order → Orders + OrderLines), value object หนึ่ง​ตัว​อาจ​กลาย​เป็น​หลาย column ใน​แถว​เดียว (MoneyTotalAmount + TotalCurrency) และ collection ที่​ปิด​สนิท​ใน code ต้อง​กลาย​เป็น foreign key ที่​ใคร​ก็ query ตรง​ได้​ใน​ฐาน​ข้อมูล

ทาง​ลัด​ที่​ล่อ​ใจ​ที่สุด​คือ “เริ่ม​จาก​ตาราง แล้ว​ปั้น class ให้​ตรง column” — ซึ่ง​พัง​ทันที​ที่​ลงมือ:

❌ version ดิบ: ปั้น class ให้ 'เหมือน​ตาราง' แล้ว model กลวง​ทันที
// เริ่มจาก schema แล้วให้ class ตาม column ทุกอย่างเลยเปิด public หมด
public class OrderRow
{
public Guid Id { get; set; }
public decimal TotalAmount { get; set; } // Money แตกเป็น column ลอย ๆ
public string TotalCurrency { get; set; } = "";
public List<OrderLineRow> Lines { get; set; } = new(); // ใครก็ Add/Clear ได้
// ไม่มี Place() ไม่มี invariant — สร้างออเดอร์ตะกร้าว่างก็ได้ Total ไม่ตรงก็ได้
}

OrderRow แถว​นี้​ตรง​กับ​ตาราง​เป๊ะ แต่​มัน​คือ aggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถือ​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง มี root เดียว​เป็น​ประตู​เข้า​และ​เป็น​หน่วย​ของ transaction เช่น Order ที่​คุม OrderLine — งาน​ของ​บท​นี้​คือ map Aggregate ทั้ง​ก้อน​ลง SQL โดย​ไม่​ทำลาย invariant ที่ root รักษา​ไว้Tactical Design ที่​ตาย​แล้ว: ไม่มี​ใคร​รักษา​กฎ​ธุรกิจ​อีก​ต่อ​ไป ใคร​ก็ order.Lines.Clear() แล้ว​เซฟ​ทับ​ได้ ใคร​ก็​ตั้ง TotalAmount ให้​ไม่​ตรง​กับ​ผล​รวม​ได้ นี่​คือ anemic model ที่​คอร์ส DDD in Code อุตส่าห์​กำจัด​ไป — เรา​กำลัง​จะ​ไม่​ยอม​ให้​ฐาน​ข้อมูล​ลาก​มัน​กลับ​มา โจทย์​จริง​ของ​คอร์ส​นี้​จึง​กลับ​ด้าน: เริ่ม​จาก model ที่​ดี แล้ว​สอน​ให้​ฐาน​ข้อมูล​ตาม model ไม่ใช่​ให้ model ตาม​ฐาน​ข้อมูล นี่​คือ Order version จริง​จาก​คอร์ส​ก่อน ที่​เรา​จะ​เก็บ​ลง SQL ทั้ง​ก้อน​โดย​ไม่​แตะ​มัน​เลย:

// FoodOrdering.Domain/Orders/ (ทวนจาก Course A บทที่ 2 + Course B บทที่ 2–3 —
// OrderLine ถูกยกระดับจาก record เป็น entity ในคอร์ส B; นี่คือรูปสุดท้ายที่เราจะ map)
public sealed record OrderId(Guid Value);
public sealed record OrderLineId(Guid Value);
public sealed record ProductId(Guid Value);
public sealed class OrderLine : Entity<OrderLineId>
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
// ...
}
public sealed class Order
{
private readonly List<OrderLine> _lines = new(); // collection ส่วนตัว
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Money Total { get; private set; } // คำนวณ ไม่ใช่ set ได้อิสระ
private Order(OrderId id, IEnumerable<OrderLine> lines) { /* คำนวณ Total, รักษา invariant */ }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) // ประตูเดียว
{
if (items.Count == 0)
throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"); // invariant ①
// ...
}
}
flowchart LR
  subgraph OBJ["โลกของวัตถุ — Domain (รวย behavior)"]
    O["Order (aggregate root)<br/>Place() รักษา invariant<br/>_lines: ปิดไว้เป็น private"]
    L["OrderLine<br/>มี OrderLineId เป็น identity"]
    M["Money (value object)<br/>Amount + Currency ไม่มี identity"]
    O -- "คุม (composition)" --> L
    O -- "Total" --> M
    L -- "UnitPrice" --> M
  end
  subgraph MAP["ชั้นแปลภาษา — EF Core OnModelCreating"]
    CFG["HasConversion • OwnsOne<br/>HasMany • backing field _lines"]
  end
  subgraph REL["โลกของตาราง — PostgreSQL (แบน)"]
    T1["ตาราง Orders<br/>Id • TotalAmount • TotalCurrency"]
    T2["ตาราง OrderLines<br/>Id • OrderId (FK) • ProductId<br/>Quantity • UnitPriceAmount • UnitPriceCurrency"]
    T1 -- "1 ต่อ N (FK OrderId)" --> T2
  end
  OBJ --> MAP --> REL

คำ​บรรยาย​ภาพ: ฝั่ง​ซ้าย​คือ graph วัตถุ​ที่ Order คุม OrderLine และ​ใช้ Money ร่วม​กัน — มี behavior และ invariant อยู่​ใน​ตัว ฝั่ง​ขวา​คือ​ตาราง​แบนๆ ใน PostgreSQL ที่​รู้จัก​แค่​แถว/column/foreign key ตรง​กลาง​คือ ชั้น​แปล​ภาษา ที่ EF Core ประกาศ​ไว้​ใน OnModelCreating ล้วนๆ — มัน​รับ​หน้าที่​ปิด​ช่องว่าง​นี้​แทน ไม่ใช่​ปล่อย​ให้ model หรือ​ฐาน​ข้อมูล​ฝั่ง​ใด​ฝั่ง​หนึ่ง​ยอมความ​ให้​อีก​ฝั่ง

สังเกต​ใน​ไดอะแกรม: ลูกศร​วิ่ง​จาก domain ไป​หา​ฐาน​ข้อมูล ผ่าน​ชั้น​แปล​ภาษา เสมอ ไม่มี​ลูกศร​ไหน​วิ่ง​กลับ​เข้าไป​แตะ domain เลย นั่น​คือ​หัวใจ​ของ Persistence IgnorancePersistence Ignoranceหลัก​ที่​ว่า domain model (Order, OrderLine, Money) ไม่​ควร​รู้​อะไร​เลย​เกี่ยว​กับ​วิธี​ที่​มัน​ถูก​บันทึก — ไม่มี attribute ของ EF Core ไม่​สืบทอด​จาก base class ใด ๆ ทุก​อย่าง​เรื่อง mapping อยู่​นอก domain ทั้งหมดArchitecture — หลัก​ที่​ว่า model domain ไม่​ควร​รู้​อะไร​เลย​เกี่ยว​กับ​วิธี​ที่​ตัวเอง​ถูก​บันทึก คอร์ส Clean Architecture บท 4 พิสูจน์​ข้อ​นี้​ให้​เห็น​แล้ว​อย่าง​เป็น​รูปธรรม: .csproj ของ FoodOrdering.Domain ไม่มี <PackageReference> ไปหา Microsoft.EntityFrameworkCore เลย​แม้แต่​ตัว​เดียว และ​ห้าม​มี​ด้วย

ผล​ที่​จับ​ต้อง​ได้​คือ ใน file Order.cs, OrderLine.cs, Money.cs จะหา attribute ของ EF Core อย่าง [Key], [Column], [Table] ไม่​เจอ​สัก​ตัว — model ไม่รู้​ด้วย​ซ้ำ​ว่า​จะ​ถูก​เก็บ​ลง PostgreSQL, SQL Server หรือ file JSON งาน​แปล​ภาษา​ทั้งหมด​ถูก​ผลัก​ไป​อยู่​ที่ OnModelCreating ใน layer โครงสร้าง​พื้นฐาน​ผ่าน Fluent API ซึ่ง​อยู่​นอก domain โดย​สิ้นเชิง ถ้า​วัน​ไหน​ต้อง​เอา attribute ของ EF Core ไป​แปะ​บน Order เพื่อ​ให้ mapping ทำงาน นั่น​คือ​สัญญาณ​ว่า Dependency Rule กำลัง​ถูก​ละเมิด และ Persistence Ignorance ถูก​ทำลาย

เหตุผล​ไม่ใช่​ความ​บริสุทธิ์​เชิง​อุดมคติ แต่​เป็น​เรื่อง​ปฏิบัติ​ล้วนๆ: model ที่​ไม่​ผูก​กับ​กลไก​การ​เก็บ​ข้อมูล​คือ model ที่ test ได้​โดย​ไม่​ต้อง​มี​ฐาน​ข้อมูล เปลี่ยน​กลยุทธ์ persistence ได้​โดย​ไม่​ต้อง​แก้​กฎ​ธุรกิจ และ​อ่าน​แล้ว​เข้าใจ domain ไม่ใช่​เข้าใจ schema ทั้ง​คอร์ส​นี้​จะ​รักษา​เส้น​แบ่ง​นี้​ไว้​ทุก​บท — mapping จะ​หนา​ขึ้น​เรื่อยๆ แต่ domain จะ​ไม่​ขยับ​เข้า​ใกล้ EF Core แม้แต่​ก้าว​เดียว

EF Core เป็น​เครื่องมือ​ปิด​ช่องว่าง​ที่​ทรง​พลัง แต่​มัน​เป็น​ดาบสอง​คม — รู้​ว่า​มัน​ช่วย​ตรง​ไหน​และ​ไม่​ช่วย​ตรง​ไหน​คือ​สิ่ง​ที่​แยก​การ map ที่​ซื่อสัตย์​ต่อ model ออก​จาก​การ map ที่​แอบ​ทำให้ model กลวง​โดย​ไม่รู้ตัว

สิ่ง​ที่ EF Core ให้ — เครื่องมือ​ครบ​ชุด​สำหรับ​ปิด​ช่องว่าง​โดย​ไม่​ต้อง​แตะ domain:

  • Value ConverterValue Converterกลไก​ของ EF Core ที่​แปลง Value Object เช่น Money หรือ strongly-typed OrderId ไป​เป็น column primitive ตอน​เขียน และ​แปลง​กลับ​ตอน​อ่าน โดยที่ domain ไม่​ต้อง​รู้​ว่า​ถูก​แปลง​เลยArchitecture แปลง OrderId/ProductId/Quantity ที่​ห่อ​ค่า​ดิบ​ไว้ ให้​กลาย​เป็น column primitive ตอน​เขียน​และ​ห่อ​กลับ​ตอน​อ่าน
  • Owned Entity TypeOwned Entity Typefeature ของ EF Core ที่ map Value Object หรือ Entity ที่​ไม่มี identity ของ​ตัวเอง (Address, Money) ให้​เป็น​ส่วน​หนึ่ง​ของ owner โดย​ไม่​ต้อง​มี​ตาราง​แยก — วิธี​มาตรฐาน​ที่สุด​ใน​การ​เก็บ Value Object ลง SQLArchitecture ฝัง Money เป็นกลุ่ม column ใน​ตาราง​เจ้าของ โดย​ไม่​ต้อง​มี​ตาราง​แยก​และ​ไม่​ต้อง​ให้ Money มี identity
  • Backing FieldBacking Fieldfield private ที่ EF Core เขียน/อ่าน​ตรง ๆ (ข้าม property) เพื่อ​ให้ Aggregate เปิดเผย​แค่ IReadOnlyCollection<OrderLine> ให้​ภายนอก แต่​ยัง​ยอม EF เติม​ข้อมูล​ลง List<OrderLine> ภายใน​ได้ — รักษา encapsulation ของ root ไว้​ไม่​ให้​ใคร​เพิ่ม/ลบ​บรรทัด​ผ่าน collection ตรง ๆArchitecture ให้ EF Core อ่าน/เขียน _lines ที่​เป็น private ตรงๆ ทำให้ aggregate เปิดเผย​แค่ IReadOnlyCollection ออก​ไป​ได้​เหมือน​เดิม
  • constructor binding ที่​สร้าง Order ผ่าน private constructor ตัว​เดิม​ตอน​โหลด​ข้อมูล​กลับ — ไม่​ต้อง​เพิ่ม parameterless constructor สาธารณะ
  • Change TrackingChange Trackingกลไก​ของ DbContext ที่​จด snapshot ค่า​ตอน entity ถูก​โหลด​เข้า​มา แล้ว​เทียบ​กับ​ค่า​ปัจจุบัน​ตอน SaveChanges เพื่อ​สร้าง UPDATE เฉพาะ column ที่​เปลี่ยน​จริง — มี​ต้นทุน จึง​ควร​ใช้ AsNoTracking() กับ query ที่​ไม่​ได้​ตั้งใจ​แก้ไขArchitecture, MigrationMigrationfile C# ที่ EF Core generate จาก diff ระหว่าง model ปัจจุบัน​กับ Model Snapshot ก่อนหน้า อธิบาย​การ​เปลี่ยน schema เป็น Up/Down ที่​ควบคุม version ได้ และ apply กับ​ฐาน​ข้อมูล​จริง​ตาม​ลำดับArchitecture และ LINQ-to-SQL ที่​จะ​เป็น​เนื้อหา​หลัก​ของบทถัดๆ ไป

สิ่ง​ที่ EF Core ไม่​ให้ — และ​เป็น​เหตุผล​ที่​คอร์ส​นี้​ต้อง​มี​ถึง 8 บท:

  • มัน​ไม่​รักษา invariant ให้​เรา — ตอน​โหลด​ข้อมูล​กลับ EF Core สร้าง​วัตถุ​ขึ้น​มา​โดยข้าม Place(...) เข้า constructor ตรงๆ ได้ ถ้า​เรา​ตั้ง mapping ไม่​ระวัง ข้อมูล​เสีย​ใน​ฐาน​ข้อมูล​ก็​กลาย​เป็น Order ที่​ละเมิด​กฎ​ได้​เงียบๆ
  • convention เริ่มต้น​ของ​มัน​เอน​ไป​ทาง anemic — มัน​จะ​ยินดี​ถ้า​เรา​เปิด public setter ทุก​ตัว​และ​เปิด collection ให้​แก้ตรงๆ (แบบ OrderRow ข้าง​บน) เรา​ต้อง​ออกแรงต้าน convention นั้น​เพื่อ​รักษา encapsulation
  • มัน​ไม่รู้​ว่า Money เป็น value object จนกว่า​เรา​จะ​บอก ไม่รู้​ว่า Order เป็น​ขอบเขต transaction จนกว่า​เรา​จะ​กำหนด และ​ไม่รู้​ว่า query ไหน​จะ​กลาย​เป็น N+1 จนกว่า​จะ​สาย

พูดสั้นๆ: EF Core map graph ของ​วัตถุ ให้​ได้ แต่​มัน​ไม่รู้จัก กฎที่​ทำให้ graph นั้น​ถูกต้อง หน้าที่​รักษา​กฎ​ยัง​เป็น​ของ​เรา​เสมอ งาน​ของ​ทั้ง​คอร์ส​คือ​ใช้​เครื่องมือ​ฝั่ง “ให้” เพื่อ​เอาชนะ​ข้อ​จำกัด​ฝั่ง “ไม่​ให้” โดย​ไม่​ยอม​ถอย​กลับ​ไป​หา model กลวง

บท​นี้​วาง​แค่​โจทย์ — ช่องว่าง​ระหว่าง​วัตถุ​กับ​ตาราง, เส้น​แบ่ง​ที่​ห้าม​ข้าม (Persistence Ignorance) และ​ขอบเขต​ของ​เครื่องมือ จาก​นี้​เรา​จะ​ไล่ map Order aggregate ทั้ง​ก้อน​ลง PostgreSQL ที​ละ​ชั้น​ความ​ยาก:

  1. Strongly-typed ID กับ value object (บท​ที่ 2) — Value Converter, Value Comparer และ Owned Entity Type เพื่อ​เก็บ OrderId/Money โดย​ไม่​ทำให้​เป็น primitive กลวง
  2. เก็บ​ทั้ง aggregate โดย​รักษา encapsulation (บท​ที่ 3) — backing field และ constructor binding เพื่อ​ให้ _lines ยัง​ปิด​สนิท
  3. Migration กับ​วิวัฒนาการ​ของ schema (บท​ที่ 4) — Model Snapshot, Up/Down และ​เหตุผล​ที่​ต้อง commit คู่​กัน​เสมอ
  4. Optimistic Concurrency (บท​ที่ 5) — Concurrency Token กัน​สอง​ธุรกรรม​เขียน​ทับ​กัน​เงียบๆ
  5. Transaction Boundary กับ Unit of Work (บท​ที่ 6) — 1 aggregate ต่อ​หนึ่ง SaveChanges, ทำไม DbContext เป็น unit of work อยู่​แล้ว และ Transactional Outbox กัน dual-write
  6. Query ให้​ไม่ N+1 (บท​ที่ 7) — Projection, Read Model และ AsNoTracking() สำหรับ​เส้นทาง​ที่​แค่​อ่าน
  7. เมื่อ EF Core ไม่ใช่​คำ​ตอบ (บท​ที่ 8) — รู้จัก​ขอบเขต​ของ EF Core และ​ทาง​เลือก​อย่าง Dapper, Marten และ​การ test persistence ด้วย Testcontainers

ทุก​บท​กลับ​มา​ที่ Order เดิม​เสมอ — ไม่มี domain ใหม่ ไม่มี building block ใหม่​ใน domain มี​แต่​การ​เก็บ model เดิม​ลง​ฐาน​ข้อมูล​จริง​อย่าง​ซื่อสัตย์ นี่​คือ​คำ​สัญญา​ของ​คอร์ส: ปลายทาง​คือ Order ก้อน​เดิม​ที่​รักษา​กฎ​ของ​ตัวเอง​ได้ ทั้ง​ใน​หน่วย​ความ​จำ​และ​ใน PostgreSQL เหมือน​กัน​ทุก​ประการ


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Persistence Ignorance — หลัก​ที่​ว่า model domain ไม่​ควร​รู้​ว่า​ตัวเอง​ถูก​บันทึก​อย่างไร เส้น​แบ่ง​ที่​ทั้ง​คอร์ส​นี้​จะ​รักษา​ไว้​ทุก​บท
  • Aggregate — กลุ่ม​ที่​มี root เดียว​เป็น​ประตู​เข้า​และ​เป็น​หน่วย​ของ transaction คือ​สิ่ง​ที่​เรา​กำลัง​จะ​เก็บ​ลง SQL ทั้ง​ก้อน
  • เก็บ Order ลง​ฐาน​ข้อมูล (Clean Architecture บท​ที่ 4) — ที​เซอร์​ที่​บท​นี้​สาน​ต่อ​เต็ม​รูป

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

Object-Relational Impedance Mismatch หมายถึงอะไร?