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

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 ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — mapping ของ​บท​นี้​อยู่​ที่ FoodOrdering.Infrastructure/Persistence/OrderConfiguration.cs ส่วน domain ที่​ถูก map อยู่​ที่ FoodOrdering.Domain/Orders/ และ​ไม่​ถูก​แตะ​เลย​แม้แต่​บรรทัด​เดียว

ก่อน​ลงมือ ทวน​สาม​ส่วน​ของ 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 ทั้งหมด

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 ใน​ตาราง​ของ​เจ้าของ อยู่​แถว​เดียวกัน​เลย”

FoodOrdering.Infrastructure/Persistence/OrderConfiguration.cs
// 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 ของ​ตัวเองMoney share ตาราง Orders และ share primary key ของ Order (EF ใช้ shadow key ที่​เป็น​ตัว​เดียว​กับ PK ของ​เจ้าของ) ลบ Order เมื่อไหร่ Money หาย​ไป​พร้อม​กัน​โดย​อัตโนมัติ เพราะ​มัน​ไม่​เคย​มี​ชีวิต​แยก​ตั้งแต่​แรก
  • ชื่อ column ควบคุม​ได้ — ถ้า​ไม่​ตั้ง HasColumnName EF จะ​ตั้ง​ให้​เป็น 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 ที่​มี​แต่ constructor Money(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() ให้​ชัด และ​ไม่​ต้อง​แบก​ความ​กำกวม​นั้น​เลย

ทำไม Money ใช้ OwnsOne แต่ OrderId ใช้ Value Converter?

ทั้ง​คู่​เป็น 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

พอ 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 จริง จึงใช้ HasMany
builder.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 ที่​มัน​สังกัด

ตอน​นี้ HasMany รู้​แล้ว​ว่า​มี collection ของ OrderLine แต่​ยัง​เหลือ​คำถาม​ที่​ยาก​ที่สุด: EF จะ เขียน บรรทัด​ที่​โหลด​มา​จาก​ฐาน​ข้อมูล​กลับ​เข้า Order ทาง​ไหน? ทาง​ที่​ห้าม​คือ​ผ่าน property Lines เพราะ Lines คืน​ค่า _lines.AsReadOnly() — เป็น ReadOnlyCollection ที่​ห่อ​ใหม่​ทุก​ครั้ง​ที่​เรียก ไม่มี setter และ Add ไม่​ได้​เลย ถ้า​เปิด property ให้​แก้​ได้ ก็​เท่ากับ​เปิด​ช่อง​ให้​ใคร​ก็ตาม order.Lines.Clear() ทับ invariant ทิ้ง — ซึ่ง​คือ anti-pattern “exposing collections” ที่​เรา​ตั้งใจ​กำจัด

❌ version ดิบ: เปิด List ออก​ตรง ๆ แล้ว encapsulation พัง​ทันที
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 Lines
builder.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 แม้แต่​นิดเดียว

ปริศนา​สุดท้าย: 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 ผิด​โดย​ไม่รู้ตัว:

  1. การ​จับ​คู่​อาศัย “ชื่อ” ล้วนๆ — ถ้า​เปลี่ยน parameter เป็น orderId โดยที่ property ชื่อ Id การ​จับ​คู่​จะ​พัง เพราะ orderId ไม่​ตรง​กับ​สมาชิก​ที่ map ไว้ ชื่อ parameter จึง​เป็น​ส่วน​หนึ่ง​ของ contract ที่ EF พึ่งพา ไม่ใช่​แค่​เรื่อง​ความ​อ่าน​ง่าย
  2. 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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง 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 / 3

OwnsOne(o => o.Total) ทำให้ EF Core เก็บ Money ลง PostgreSQL อย่างไร?