ทำไม 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 เต็มของคอร์สนี้อยู่ที่ 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 ในแถวเดียว (Money → TotalAmount + TotalCurrency) และ collection ที่ปิดสนิทใน code ต้องกลายเป็น foreign key ที่ใครก็ query ตรงได้ในฐานข้อมูล
ทางลัดที่ล่อใจที่สุดคือ “เริ่มจากตาราง แล้วปั้น class ให้ตรง column” — ซึ่งพังทันทีที่ลงมือ:
// เริ่มจาก 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 หรือฐานข้อมูลฝั่งใดฝั่งหนึ่งยอมความให้อีกฝั่ง
Persistence Ignorance — domain ต้องไม่รู้จัก EF Core
หัวข้อที่มีชื่อว่า “Persistence Ignorance — domain ต้องไม่รู้จัก EF Core”สังเกตในไดอะแกรม: ลูกศรวิ่งจาก 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 ให้อะไร และไม่ให้อะไรกับ model ที่ดี
หัวข้อที่มีชื่อว่า “EF Core ให้อะไร และไม่ให้อะไรกับ model ที่ดี”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 ทีละชั้นความยาก:
- Strongly-typed ID กับ value object (บทที่ 2) — Value Converter, Value Comparer และ Owned Entity Type เพื่อเก็บ
OrderId/Moneyโดยไม่ทำให้เป็น primitive กลวง - เก็บทั้ง aggregate โดยรักษา encapsulation (บทที่ 3) — backing field และ constructor binding เพื่อให้
_linesยังปิดสนิท - Migration กับวิวัฒนาการของ schema (บทที่ 4) — Model Snapshot, Up/Down และเหตุผลที่ต้อง commit คู่กันเสมอ
- Optimistic Concurrency (บทที่ 5) — Concurrency Token กันสองธุรกรรมเขียนทับกันเงียบๆ
- Transaction Boundary กับ Unit of Work (บทที่ 6) — 1 aggregate ต่อหนึ่ง
SaveChanges, ทำไมDbContextเป็น unit of work อยู่แล้ว และ Transactional Outbox กัน dual-write - Query ให้ไม่ N+1 (บทที่ 7) — Projection, Read Model และ
AsNoTracking()สำหรับเส้นทางที่แค่อ่าน - เมื่อ EF Core ไม่ใช่คำตอบ (บทที่ 8) — รู้จักขอบเขตของ EF Core และทางเลือกอย่าง Dapper, Marten และการ test persistence ด้วย Testcontainers
ทุกบทกลับมาที่ Order เดิมเสมอ — ไม่มี domain ใหม่ ไม่มี building block ใหม่ใน domain มีแต่การเก็บ model เดิมลงฐานข้อมูลจริงอย่างซื่อสัตย์ นี่คือคำสัญญาของคอร์ส: ปลายทางคือ Order ก้อนเดิมที่รักษากฎของตัวเองได้ ทั้งในหน่วยความจำและใน PostgreSQL เหมือนกันทุกประการ
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Persistence Ignorance — หลักที่ว่า model domain ไม่ควรรู้ว่าตัวเองถูกบันทึกอย่างไร เส้นแบ่งที่ทั้งคอร์สนี้จะรักษาไว้ทุกบท
- Aggregate — กลุ่มที่มี root เดียวเป็นประตูเข้าและเป็นหน่วยของ transaction คือสิ่งที่เรากำลังจะเก็บลง SQL ทั้งก้อน
- เก็บ Order ลงฐานข้อมูล (Clean Architecture บทที่ 4) — ทีเซอร์ที่บทนี้สานต่อเต็มรูป
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3Object-Relational Impedance Mismatch หมายถึงอะไร?