Owned Types & backing fields
บทที่ 2 เพิ่งเก็บค่าเดี่ยวๆ ได้แล้ว — OrderId/ProductId ที่ห่อ Guid ไว้กลายเป็น column ผ่าน Value Converter และ Money ก็เริ่มลงตารางในฐานะ owned type ทีเซอร์เดียวกันนี้เคยโผล่ในบทที่ 4 ของคอร์ส Clean Architecture .NET มาแล้วแบบผ่านๆ: OwnsOne(Total), HasMany(Lines), _lines backing field, และ rehydration ผ่าน private constructor บทนั้นแค่โชว์ให้เห็นว่า “ทำได้” บทนี้คือภาคที่ลงไปดูว่า มันทำงานยังไงจริงๆ และเลือกยังไงให้ถูก
โจทย์ของบทนี้แคบแต่ลึก: เก็บ ทั้ง Order aggregate — ทั้งก้อน รวมบรรทัดลูกและ value object — ลง PostgreSQL โดยที่ aggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้าและเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLine — งานของบทนี้คือ map Aggregate ทั้งก้อนลง SQL โดยไม่ทำลาย invariant ที่ root รักษาไว้Tactical Design ยังปิดสนิทเหมือนตอนอยู่ในหน่วยความจำทุกประการ ไม่มีใครแอบ Add บรรทัด ไม่มีใครตั้ง Total ให้เพี้ยน และ EF ยังสร้างมันกลับคืนมาได้โดยไม่ต้องเปิด setter สาธารณะสักตัว เราจะไล่ทีละกลไก: owned type ที่พับ Money, การตัดสินใจว่า OrderLine เป็น owned หรือ entity, backing field ที่กันไม่ให้ collection รั่ว และ constructor binding ที่ปิดจ๊อบตอนโหลดกลับ
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — mapping ของบทนี้อยู่ที่ FoodOrdering.Infrastructure/Persistence/OrderConfiguration.cs ส่วน domain ที่ถูก map อยู่ที่ FoodOrdering.Domain/Orders/ และไม่ถูกแตะเลยแม้แต่บรรทัดเดียว
ทวนรูปสุดท้ายของ Order ที่เราจะเก็บ
หัวข้อที่มีชื่อว่า “ทวนรูปสุดท้ายของ Order ที่เราจะเก็บ”ก่อนลงมือ ทวนสามส่วนของ Order ที่บทนี้ต้อง map ให้ครบ: Total ที่เป็น Money, _lines ที่เป็น collection ปิด และ private constructor ที่เป็นทางเดียวที่สร้างตัวมันขึ้นมาได้ นี่คือรูปสุดท้ายจากคอร์สก่อน ที่เราจะเก็บลง SQL โดยไม่แก้มันเลย
// FoodOrdering.Domain/Orders/ — ทวนจาก Course A บท2 + Course B บท2–3// (OrderLine เคยเป็น record ใน Course A บท2 แล้วถูกยกระดับเป็น Entity<OrderLineId> ใน Course B บท3)public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);public sealed record Quantity(int Value);
public sealed record Money(decimal Amount, string Currency){ public static Money Thb(decimal amount) => new(amount, "THB");}
public sealed class OrderLine : Entity<OrderLineId> // Entity<TId> ทวนจาก Course B{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; } // Id : OrderLineId มาจาก base Entity<OrderLineId> — นี่คือ identity จริงของบรรทัด}
public sealed class Order{ private readonly List<OrderLine> _lines = new(); // ที่เก็บจริง — ปิด public OrderId Id { get; private set; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); // เปิดออกแบบอ่านอย่างเดียว public Money Total { get; private set; } // คำนวณ ไม่ใช่ตั้งได้อิสระ
private Order(OrderId id, IEnumerable<OrderLine> lines) // ทางเดียวที่วัตถุนี้เกิดได้ { // set Id, เติม _lines จาก lines, แล้วคำนวณ Total จากผลรวมทุกบรรทัด }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) // ประตูเดียวของ domain { if (items.Count == 0) throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"); // invariant return new Order(id, items); }}จุดที่ต้องจำไว้ตลอดบทนี้: file นี้อยู่ใน FoodOrdering.Domain ซึ่งไม่มี reference ไปหา EF Core เลย ทุกอย่างที่จะเขียนต่อจากนี้อยู่ใน class configuration คนละ project — นั่นคือ Persistence IgnorancePersistence Ignoranceหลักที่ว่า domain model (Order, OrderLine, Money) ไม่ควรรู้อะไรเลยเกี่ยวกับวิธีที่มันถูกบันทึก — ไม่มี attribute ของ EF Core ไม่สืบทอดจาก base class ใด ๆ ทุกอย่างเรื่อง mapping อยู่นอก domain ทั้งหมดArchitecture ที่บทที่ 1 วางกฎไว้ domain ไม่รู้ด้วยซ้ำว่ามันกำลังจะกลายเป็นสองตารางใน PostgreSQL
ภาพรวม: 1 aggregate กลายเป็นสองตาราง (และ column ที่พับไว้)
หัวข้อที่มีชื่อว่า “ภาพรวม: 1 aggregate กลายเป็นสองตาราง (และ column ที่พับไว้)”Order หนึ่งก้อนไม่ได้ลงเป็นตารางเดียว และไม่ได้ลงเป็นสามตารางด้วย — มันลงเป็น สองตารางกับหนึ่งชุด column ที่พับเข้าไป ความต่างระหว่าง “ตารางแยก” กับ “column ที่พับ” คือหัวใจของทั้งบท
flowchart TB
subgraph MEM["Order aggregate — ในหน่วยความจำ (ปิดสนิท)"]
ORD["Order (root)<br/>Id • Total • _lines (backing field, private)"]
OL["OrderLine : Entity ที่มี OrderLineId<br/>มี identity ของตัวเอง"]
MON["Money (value object)<br/>Amount + Currency — ไม่มี identity"]
ORD -- "_lines: List ที่เปิดออกแค่ AsReadOnly()" --> OL
ORD -- "Total" --> MON
OL -- "UnitPrice" --> MON
end
subgraph PG["PostgreSQL"]
T1["ตาราง Orders<br/>Id (PK)<br/>TotalAmount • TotalCurrency ← Money พับเข้าแถวเดียวกัน"]
T2["ตาราง OrderLines<br/>Id (PK = OrderLineId)<br/>OrderId (FK) • ProductId<br/>Quantity • UnitPriceAmount • UnitPriceCurrency"]
T1 -- "1 ต่อ N (FK OrderId, ลบแบบ cascade)" --> T2
end
MON -. "OwnsOne: พับเป็น column ไม่มีตารางแยก" .-> T1
ORD -. "HasMany + backing field _lines" .-> T2
คำบรรยายภาพ: Money ที่ไม่มี identity ถูก พับ เข้าไปเป็น column TotalAmount/TotalCurrency ในแถวเดียวกับ Orders (owned type — ไม่มีตารางแยก) ส่วน OrderLine ที่มี OrderLineId เป็น identity จริงได้ตารางของตัวเองชื่อ OrderLines พร้อม foreign key กลับมาที่ Orders เส้นประคือหน้าที่ของ configuration: OwnsOne พับ value object, HasMany + backing field _lines ผูก collection เข้ากับตารางลูก ทั้งสองเส้นทำงานอยู่นอก domain ทั้งหมด
OwnsOne(o => o.Total) — พับ Money เข้าเป็น column
หัวข้อที่มีชื่อว่า “OwnsOne(o => o.Total) — พับ Money เข้าเป็น column”Money ไม่มี identity ของตัวเอง — เงิน 50 บาทใบนี้กับ 50 บาทใบนั้น “เท่ากัน” โดยดูจากค่า ไม่ใช่จากตัวตน นั่นคือนิยามของ value object และเป็นเหตุผลที่มันไม่ควรมีตารางของตัวเองพร้อม primary key เพราะ primary key จะให้ identity กับสิ่งที่ไม่ควรมี วิธีมาตรฐานที่สุดในการเก็บ value object แบบนี้คือ Owned Entity TypeOwned Entity Typefeature ของ EF Core ที่ map Value Object หรือ Entity ที่ไม่มี identity ของตัวเอง (Address, Money) ให้เป็นส่วนหนึ่งของ owner โดยไม่ต้องมีตารางแยก — วิธีมาตรฐานที่สุดในการเก็บ Value Object ลง SQLArchitecture ผ่าน OwnsOne ซึ่งบอก EF Core ว่า “เอา field ของมันไปพับเป็น column ในตารางของเจ้าของ อยู่แถวเดียวกันเลย”
// config สำหรับ Order ที่ทวนไว้ด้านบน — class นี้อยู่นอก domain domain ไม่รู้จักมันpublic sealed class OrderConfiguration : IEntityTypeConfiguration<Order>{ public void Configure(EntityTypeBuilder<Order> builder) { builder.ToTable("Orders"); builder.HasKey(o => o.Id);
builder.OwnsOne(o => o.Total, total => { total.Property(m => m.Amount) .HasColumnName("TotalAmount") .HasPrecision(12, 2); // Npgsql map decimal → numeric(12,2) total.Property(m => m.Currency) .HasColumnName("TotalCurrency") .HasMaxLength(3) // 'THB' .IsRequired(); }); builder.Navigation(o => o.Total).IsRequired(); // owned reference ห้ามเป็น null // ... }}สิ่งที่ OwnsOne ทำจริงๆ และเป็นเหตุผลที่มันต่างจากตารางแยก:
- ไม่มีตารางใหม่ ไม่มี key ของตัวเอง —
Moneyshare ตารางOrdersและ share primary key ของOrder(EF ใช้ shadow key ที่เป็นตัวเดียวกับ PK ของเจ้าของ) ลบOrderเมื่อไหร่Moneyหายไปพร้อมกันโดยอัตโนมัติ เพราะมันไม่เคยมีชีวิตแยกตั้งแต่แรก - ชื่อ column ควบคุมได้ — ถ้าไม่ตั้ง
HasColumnNameEF จะตั้งให้เป็นTotal_Amount/Total_Currencyตาม convention เราตั้งเองให้เป็นTotalAmount/TotalCurrencyเพื่อให้ schema อ่านง่ายและคุม migration ได้ตรงๆ - facet ของแต่ละ column ตั้งได้ —
HasPrecision(12, 2)กันไม่ให้decimalถูกปัดทิ้งทศนิยม และHasMaxLength(3)บังคับความยาว currency code ทั้งหมดนี้เป็นเรื่องของ column ไม่ใช่เรื่องของ domain —Moneyไม่รู้ด้วยซ้ำ - owned type ก็ใช้ constructor binding เหมือนกัน —
Moneyเป็น positional record ที่มีแต่ constructorMoney(decimal Amount, string Currency)และ property แบบ get-only EF สร้างมันกลับโดยจับคู่ชื่อ column กับชื่อ parameter (Amount,Currency) แล้วเรียก constructor ของ record ตรงๆ ไม่ต้องมี setter — หลักการเดียวกับที่เราจะเจอกับOrderตอนท้ายบท
จุดที่ควรระวังและ Course A บท4 ไม่ได้พูดถึง: owned reference เป็น required โดย default ถ้าอยากให้ owned type เป็น optional (เช่นบางกรณี Money เป็น null ได้) ต้องทำให้ทุก column ของมัน nullable แล้ว EF จะตีความว่า “ทุก column เป็น null พร้อมกัน = owned instance เป็น null” ซึ่งเป็นกับดักที่ทำให้ค่าเงิน 0 กับ “ไม่มีค่าเงิน” ปนกันได้ง่าย ในเคสของเรา Total มีเสมอ เราจึงยืนยัน IsRequired() ให้ชัด และไม่ต้องแบกความกำกวมนั้นเลย
ทั้งคู่เป็น value object ที่ไม่มี identity เหมือนกัน แต่ต่างกันที่จำนวน column ที่ต้องเก็บ OrderId ห่อค่าเดียว (Guid) จึงพอดีกับ Value ConverterValue Converterกลไกของ EF Core ที่แปลง Value Object เช่น Money หรือ strongly-typed OrderId ไปเป็น column primitive ตอนเขียน และแปลงกลับตอนอ่าน โดยที่ domain ไม่ต้องรู้ว่าถูกแปลงเลยArchitecture ที่แปลงไป-กลับเป็น 1 column ส่วน Money มี2 field (Amount + Currency) ที่ต้องอยู่ด้วยกัน — value converter ทำแบบหลาย column ไม่ได้ owned type จึงเป็นเครื่องมือที่ถูกต้อง กติกาคร่าวๆ: VO ค่าเดียว → converter, VO หลาย field → owned type
Owned collection หรือ entity จริง? — ทำไม OrderLine ไม่ใช่ owned
หัวข้อที่มีชื่อว่า “Owned collection หรือ entity จริง? — ทำไม OrderLine ไม่ใช่ owned”พอ Money ลงเป็น owned type ได้สวยๆ คำถามถัดมาที่ล่อใจคือ “งั้น OrderLine ก็ใช้ OwnsMany พับเป็น owned collection เลยสิ” — และนี่คือจุดที่ต้องหยุดคิด เพราะทางเลือกสองทางนี้ตัดสินจาก domain ไม่ใช่ความสะดวก
OwnsManyเหมาะกับ collection ของ value object ที่ ไม่มี identity — EF จะสร้างตารางลูกให้ก็จริง แต่บรรทัดในนั้นไม่มี key ที่มาจาก domain (EF ปั้น composite key จาก FK ของเจ้าของ + ลำดับ) อ้างอิงจากภายนอกไม่ได้ และไม่มีDbSetของตัวเองHasManyไปยัง entity จริง เหมาะกับลูกที่ มี identity ของตัวเอง — มี primary key ที่มาจาก domain มีชีวิตที่ track ได้เป็นชิ้น
OrderLine ของเราเป็น Entity<OrderLineId> — มันมี OrderLineId เป็น identity จริง (นี่คือการยกระดับที่เกิดใน Course B บท3 ตอนที่มันเลิกเป็น record) เมื่อสิ่งหนึ่งมี identity มันคือ entity ไม่ใช่ value object และ entity ควร map ด้วย HasMany ไม่ใช่ OwnsMany การฝืนใช้ OwnsMany กับสิ่งที่มี identity จริงคือการโยน OrderLineId ทิ้งแล้วให้ EF ปั้น key ปลอมมาแทน — เท่ากับลบข้อมูล domain ออกเพื่อความสะดวกของ ORM
// ต่อจาก OrderConfiguration.Configure(...) — OrderLine เป็น entity จริง จึงใช้ HasManybuilder.HasMany(o => o.Lines) // นำทางไปยัง collection ที่เปิดออกแบบ read-only .WithOne() // OrderLine ไม่ต้องมี navigation ย้อนกลับหา Order .HasForeignKey("OrderId") // shadow FK ในตาราง OrderLines — domain ไม่เห็น column นี้ .OnDelete(DeleteBehavior.Cascade); // ลบ Order → ลบบรรทัดทั้งหมด (lifecycle ของ aggregate)แต่ “เป็น entity จริง” ไม่ได้แปลว่ามันหลุดออกจากขอบเขตของ aggregate เราจงใจ ไม่ ประกาศ DbSet<OrderLine> ใน FoodOrderingDbContext เลย ผลคือไม่มีใคร query OrderLine เดี่ยวๆ ได้ — เข้าถึงได้ทางเดียวคือผ่าน Order.Lines OrderLine จึงมีสองคุณสมบัติพร้อมกัน: มี identity จริงในฐานข้อมูล (ตารางของตัวเอง, PK เป็น OrderLineId) แต่ยังเป็นพลเมืองภายในของ aggregate ที่โลกภายนอกแตะไม่ได้ — และ OnDelete(Cascade) ก็สะท้อน lifecycle นั้น: บรรทัดไม่มีชีวิตนอก Order ที่มันสังกัด
_lines: backing field ที่ทำให้ collection ยังปิดสนิท
หัวข้อที่มีชื่อว่า “_lines: backing field ที่ทำให้ collection ยังปิดสนิท”ตอนนี้ HasMany รู้แล้วว่ามี collection ของ OrderLine แต่ยังเหลือคำถามที่ยากที่สุด: EF จะ เขียน บรรทัดที่โหลดมาจากฐานข้อมูลกลับเข้า Order ทางไหน? ทางที่ห้ามคือผ่าน property Lines เพราะ Lines คืนค่า _lines.AsReadOnly() — เป็น ReadOnlyCollection ที่ห่อใหม่ทุกครั้งที่เรียก ไม่มี setter และ Add ไม่ได้เลย ถ้าเปิด property ให้แก้ได้ ก็เท่ากับเปิดช่องให้ใครก็ตาม order.Lines.Clear() ทับ invariant ทิ้ง — ซึ่งคือ anti-pattern “exposing collections” ที่เราตั้งใจกำจัด
public class Order{ public List<OrderLine> Lines { get; } = new(); // ใครก็ Lines.Add(...) / Lines.Clear() ได้ // Total ไม่มีทางตรงกับผลรวมอีกต่อไป — ไม่มีใครรักษา invariant}ทางออกคือ Backing FieldBacking Fieldfield private ที่ EF Core เขียน/อ่านตรง ๆ (ข้าม property) เพื่อให้ Aggregate เปิดเผยแค่ IReadOnlyCollection<OrderLine> ให้ภายนอก แต่ยังยอม EF เติมข้อมูลลง List<OrderLine> ภายในได้ — รักษา encapsulation ของ root ไว้ไม่ให้ใครเพิ่ม/ลบบรรทัดผ่าน collection ตรง ๆArchitecture — บอก EF ให้อ่าน/เขียน field _lines ที่เป็น private List<OrderLine> โดยตรง ข้าม property Lines ไปเลย aggregate จึงเปิดออกแค่ IReadOnlyCollection ให้โลกภายนอก แต่ EF ยังเติม List จริงข้างในได้เหมือนเดิม
// บอก EF ให้เข้าถึง collection ผ่าน field _lines ตรง ๆ ไม่ผ่าน property Linesbuilder.Navigation(o => o.Lines) .HasField("_lines") .UsePropertyAccessMode(PropertyAccessMode.Field);รายละเอียดที่ทำให้บรรทัดนี้สำคัญกว่าที่เห็น และเป็นส่วนที่ Course A บท4 แค่แปะไว้เฉยๆ:
- convention หาเจอเองก็จริง แต่ระบุให้ชัดดีกว่า — EF มี convention จับคู่ navigation ชื่อ
Linesกับ field ชื่อ_lines(หรือ_Lines,m_lines,lines) ให้อยู่แล้ว แต่การเขียนHasField("_lines")ชัดๆ ทำให้ mapping ไม่พังเงียบๆ ถ้ามีคนเปลี่ยนชื่อ field ในอนาคต PropertyAccessMode.Fieldหมายถึงทั้งอ่านและเขียนผ่าน field — ถ้าปล่อยให้ EF อ่านผ่าน property มันจะได้ReadOnlyCollectionตัวใหม่ทุกครั้ง ทำให้ Change TrackingChange Trackingกลไกของ DbContext ที่จด snapshot ค่าตอน entity ถูกโหลดเข้ามา แล้วเทียบกับค่าปัจจุบันตอน SaveChanges เพื่อสร้าง UPDATE เฉพาะ column ที่เปลี่ยนจริง — มีต้นทุน จึงควรใช้ AsNoTracking() กับ query ที่ไม่ได้ตั้งใจแก้ไขArchitecture สับสนว่าอะไรเปลี่ยนบ้าง การบังคับFieldให้ EF อ่าน/เขียนListตัวจริงเสมอ change tracking จึงเทียบ snapshot ได้ถูกต้อง- มีโหมด
FieldDuringConstructionด้วย — โหมดนั้นเขียนผ่าน field ตอน materialize แต่อ่านผ่าน property หลังจากนั้น เหมาะกับ property ที่ set ได้ แต่ของเราLinesเป็น read-only ล้วน อ่านผ่าน property ไม่มีประโยชน์Fieldจึงเป็นตัวเลือกที่ปลอดภัยที่สุด
สรุปสั้นๆ: backing field คือสิ่งที่ทำให้ “model ที่ปิดสนิท” กับ “model ที่ EF เติมข้อมูลได้” อยู่ร่วมกันได้ — เราไม่ต้องเปิด setter หรือเปิด List ออกเพื่อเอาใจ ORM แม้แต่นิดเดียว
EF สร้าง Order คืนมาได้อย่างไร — constructor binding
หัวข้อที่มีชื่อว่า “EF สร้าง Order คืนมาได้อย่างไร — constructor binding”ปริศนาสุดท้าย: Order ไม่มี setter สาธารณะ ไม่มี parameterless constructor สาธารณะ มีแต่ private Order(OrderId id, IEnumerable<OrderLine> lines) กับ factory Place(...) แล้ว EF โหลดข้อมูลจากฐานข้อมูลกลับมาเป็น Order ได้ยังไง โดยไม่ต้องให้เราเจาะรูอะไรใน domain เลย?
คำตอบคือ constructor binding — ความสามารถที่เสถียรมาตั้งแต่ EF Core 3.0: ตอน materialize ผลจาก query EF จะมองหา constructor (จะ private ก็ได้) แล้ว จับคู่ชื่อ parameter แบบไม่สนตัวพิมพ์เล็กใหญ่ กับสมาชิกที่ map ไว้ แต่มีขอบเขตที่ต้องรู้ให้ชัด: constructor binding รับได้เฉพาะ parameter ที่เป็นสมาชิก scalar (property ที่ map เป็น column) กับ service เท่านั้น — collection navigation ส่งผ่าน constructor ไม่ได้ ในเคสของเรา parameter id จึงจับคู่กับ key Id ได้ (ผ่าน value converter ของ OrderId) EF สร้าง Order ขึ้นผ่าน private ctor ด้วยค่านั้น แล้ว เติม _lines ให้ทีหลังผ่าน backing field ที่เรา map ด้วย HasField("_lines") ในหัวข้อก่อน ไม่ได้ยัด collection เข้ามาทาง parameter — ทั้งหมดนี้ไม่ต้องมี setter และไม่ต้องเพิ่ม constructor ว่างสาธารณะ
// ใน Order (ทวนจากด้านบน) — constructor binding จับได้แค่ scalar: 'id' → key Id ผ่าน value converter// ส่วน _lines EF เติมทีหลังผ่าน backing field ไม่ได้ส่งผ่าน parameter (ดูหมายเหตุใต้ block)private Order(OrderId id, IEnumerable<OrderLine> lines) // ← ซิกเนเจอร์เต็มคือเส้นทางของ Place(){ // เส้นทาง Place(): set Id, เติม _lines จาก lines, คำนวณ Total}หมายเหตุตามหลัก EF จริงที่ Course A บท4 ข้ามไป: constructor ที่ EF จับ bind ได้ต้องมีแต่ parameter scalar ล้วน — ใน project จริง private ctor สำหรับ rehydrate จึงมักรับแค่ OrderId id ตัวเดียว ส่วน lines ในซิกเนเจอร์ด้านบนคือทางที่ Place() ใช้สร้างวัตถุ ไม่ใช่ช่องที่ EF ป้อนค่า เราคงซิกเนเจอร์เต็มไว้ให้ต่อเนื่องกับบทก่อน แต่ให้จำหลักไว้ว่า EF ส่งบรรทัดที่โหลดมาเข้าทาง _lines backing field เท่านั้น ถ้า constructor เดียวที่มีบังคับ parameter collection จริงๆ EF จะ bind ไม่ได้แล้วฟ้อง “no suitable constructor” — เพราะ collection navigation ไม่ใช่สิ่งที่ constructor binding ป้อนให้ได้
มีสองข้อสำคัญที่ต้องเข้าใจให้ตรง มิฉะนั้นจะตั้ง mapping ผิดโดยไม่รู้ตัว:
- การจับคู่อาศัย “ชื่อ” ล้วนๆ — ถ้าเปลี่ยน parameter เป็น
orderIdโดยที่ property ชื่อIdการจับคู่จะพัง เพราะorderIdไม่ตรงกับสมาชิกที่ map ไว้ ชื่อ parameter จึงเป็นส่วนหนึ่งของ contract ที่ EF พึ่งพา ไม่ใช่แค่เรื่องความอ่านง่าย - rehydration เข้า constructor ไม่ใช่เข้า
Place()— เพราะ EF เรียก constructor ตรงๆ มันจึง ข้าม guard ที่อยู่ในPlace()(เช็ก “ตะกร้าว่าง”) ไป นี่คือสิ่งที่บทที่ 1 เตือนไว้: EF map graph ของวัตถุได้ แต่ไม่รักษา invariant ให้ ถ้าฐานข้อมูลมีแถวเสีย EF ก็ยินดีปั้นOrderที่ละเมิดกฎกลับมาให้เงียบๆ หน้าที่กันข้อมูลเสียตั้งแต่ ตอนเขียน จึงยังเป็นของเราเสมอ
เมื่อสามชิ้นนี้ประกอบกัน — OwnsOne พับ Money, HasMany + backing field ผูก _lines, และ constructor binding เรียก private ctor — Order ก้อนเดียวกันก็เดินทางไป-กลับระหว่างหน่วยความจำกับ PostgreSQL ได้ครบ โดยที่ไม่มีบรรทัดไหนใน domain ต้องรู้จัก EF Core เลย นั่นคือเป้าหมายของทั้งบท: ฐานข้อมูลตาม model ไม่ใช่ model ตามฐานข้อมูล
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Value Object — สิ่งที่ตัดสินโดยค่า ไม่ใช่ตัวตน คือเหตุผลที่
Moneyควรพับเป็น owned type แทนที่จะมีตารางและ key ของตัวเอง - Entity — สิ่งที่มี identity ต่อเนื่อง คือเหตุผลที่
OrderLineเป็น entity จริง (map ด้วยHasMany) ไม่ใช่ owned collection - Exposing Collections (anti-pattern) — การเปิด
Listออกตรงๆ ที่ backing field_lines+AsReadOnly()มีไว้ป้องกัน
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3OwnsOne(o => o.Total) ทำให้ EF Core เก็บ Money ลง PostgreSQL อย่างไร?