Strongly-typed IDs ด้วย Value Converter
บทที่แล้ววางโจทย์ไว้ว่า EF Core จะปิดช่องว่างระหว่างวัตถุกับตารางให้ ผ่านชั้นแปลภาษาใน OnModelCreating โดยไม่แตะ domain บทนี้ลงมือเขียนชั้นแปลภาษานั้นเป็นครั้งแรก กับสิ่งที่เล็กที่สุดแต่สะดุดเร็วที่สุด: OrderId model ของเราไม่ได้ใช้ Guid ลอยๆ เป็น identity แต่ห่อมันไว้ใน record OrderId(Guid Value) เพื่อไม่ให้เอา id ของ Order ไปสลับกับ id ของ Product ได้โดยบังเอิญ ปัญหาคือ EF Core ไม่รู้จัก OrderId และจะ ไม่ เก็บมันเป็น column uuid ให้เองเลย
หัวใจของบทนี้คือเครื่องมือสองชิ้นที่ทำงานคู่กัน — Value ConverterValue Converterกลไกของ EF Core ที่แปลง Value Object เช่น Money หรือ strongly-typed OrderId ไปเป็น column primitive ตอนเขียน และแปลงกลับตอนอ่าน โดยที่ domain ไม่ต้องรู้ว่าถูกแปลงเลยArchitecture ที่แปลง OrderId ไป-กลับกับค่าดิบ และ Value ComparerValue Comparerตัวบอก EF Core ว่าจะ 'เทียบค่าเท่ากัน' และ snapshot ของ type ที่ไม่ใช่ primitive อย่างไร (เช่น record VO หรือ owned type ที่มี collection ภายใน) จำเป็นเมื่อ default reference equality ของ .NET ใช้ไม่ได้ ไม่งั้น change tracking จะไม่รู้ว่าค่าเปลี่ยนจริงArchitecture ที่บอก EF Core ว่าจะเทียบและ snapshot ค่าที่แปลงแล้วอย่างไร — โดยที่ AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้าและเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLine — งานของบทนี้คือ map Aggregate ทั้งก้อนลง SQL โดยไม่ทำลาย invariant ที่ root รักษาไว้Tactical Design Order ยังคงเป็นก้อนเดิมที่ไม่รู้จัก EF Core ตามหลัก Persistence IgnorancePersistence Ignoranceหลักที่ว่า domain model (Order, OrderLine, Money) ไม่ควรรู้อะไรเลยเกี่ยวกับวิธีที่มันถูกบันทึก — ไม่มี attribute ของ EF Core ไม่สืบทอดจาก base class ใด ๆ ทุกอย่างเรื่อง mapping อยู่นอก domain ทั้งหมดArchitecture และ column ในฐานข้อมูลยังเป็น uuid ล้วนๆ ที่ query กับ index ได้ตามปกติ
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — code บทนี้อยู่ที่ FoodOrdering.Infrastructure/Persistence/Converters/ และการลงทะเบียนอยู่ใน FoodOrderingDbContext
ทำไม record ID ถึงไม่กลายเป็น column เอง
หัวข้อที่มีชื่อว่า “ทำไม record ID ถึงไม่กลายเป็น column เอง”ทวน model ที่เราจะเก็บก่อน — นี่คือรูปสุดท้ายของ Order aggregate ที่ปั้นไว้ในคอร์สก่อนหน้า เราจะ map มันทั้งก้อนโดยไม่แก้แม้แต่บรรทัดเดียว:
// FoodOrdering.Domain/Orders/ — ทวนจาก Course A บทที่ 2 + Course B บทที่ 2–3// (OrderLine เคยเป็น record ใน Course A แล้วถูกยกระดับเป็น entity ใน Course B; นี่คือรูปสุดท้าย)public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value);public sealed record ProductId(Guid Value);
public sealed class OrderLine : Entity<OrderLineId> // entity ที่มี identity เป็น OrderLineId{ public ProductId ProductId { get; } public Money UnitPrice { get; } // ...}
public sealed class Order{ public OrderId Id { get; } // ← ปมอยู่ตรงนี้: ชนิดเป็น OrderId ไม่ใช่ Guid public OrderStatus Status { get; private set; } public Money Total { get; private set; }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { /* รักษา invariant */ }}ปัญหาคือ EF Core รู้จักชนิด “สเกลาร์” ที่ map เป็น column ได้เองอยู่ชุดหนึ่ง — Guid, int, string, decimal และเพื่อนๆ — แต่ OrderId ไม่อยู่ในชุดนั้น มันเป็น reference type ที่ EF Core ไม่เคยเห็น พอเจอ property ชนิดนี้ convention เริ่มต้นจึง เดาผิด: มันสันนิษฐานว่า OrderId คือ entity อีกตัวหนึ่งที่ต้องมีตารางและ primary key ของตัวเอง แล้วล้มตอนสร้าง model
public sealed class FoodOrderingDbContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>().HasKey(o => o.Id); // ไม่ได้บอก EF ว่า OrderId แปลงเป็น column อะไร // → EF มอง OrderId เป็น "entity อีกตัว" ที่ต้องมี key ของตัวเอง แล้วโยน error ตอน build model: // The entity type 'OrderId' requires a primary key to be defined. }}เส้นทางที่ล่อใจคือยอมแพ้แล้วเปลี่ยน Order.Id ให้เป็น Guid ดิบๆ เพื่อให้ EF Core พอใจ — แต่นั่นคือการเอา type safety ที่ domain อุตส่าห์สร้างไปทิ้ง เปิดช่องให้เอา Guid ของ Product ไปใส่ช่องที่ต้องการ OrderId ได้อีกครั้ง โจทย์จริงกลับด้าน: เราจะ สอนให้ EF Core รู้จัก OrderId ไม่ใช่ทำให้ domain โง่ลงตาม
Value Converter: แปลง OrderId ไป-กลับกับ uuid
หัวข้อที่มีชื่อว่า “Value Converter: แปลง OrderId ไป-กลับกับ uuid”HasConversion คือคำตอบ มันบอก EF Core ว่า “property นี้ใน code เป็น OrderId แต่ในฐานข้อมูลให้เก็บเป็น Guid” พร้อมสูตรแปลงสองทิศทาง: ตอนเขียนให้แกะค่า Value ออกมา ตอนอ่านให้ห่อกลับเป็น OrderId เราห่อสูตรนี้ไว้ใน class converter ที่ตั้งชื่อชัดๆ เพื่อเอาไปใช้ซ้ำได้:
// FoodOrdering.Infrastructure/Persistence/Converters/using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
public sealed class OrderIdConverter : ValueConverter<OrderId, Guid>{ public OrderIdConverter() : base( id => id.Value, // toProvider: เขียนลง DB → แกะ Guid ออกมา value => new OrderId(value)) // fromProvider: อ่านจาก DB → ห่อกลับเป็น OrderId { }}จากนั้นผูก converter เข้ากับ property Id ใน mapping ของ Order column ที่ได้เป็น uuid ล้วน ไม่มีร่องรอยว่ามันเคยเป็น OrderId มาก่อน:
modelBuilder.Entity<Order>(order =>{ order.HasKey(o => o.Id); order.Property(o => o.Id) .HasConversion(new OrderIdConverter()) .HasColumnType("uuid"); // column ยังเป็น uuid ล้วน ไม่ใช่ตารางแยก});flowchart LR
subgraph DOM["domain — Persistence Ignorance (ไม่รู้จัก EF Core)"]
ID["OrderId (record)<br/>ห่อ Guid Value ไว้ข้างใน"]
end
subgraph CONV["Value Converter ใน OnModelCreating"]
VC["OrderIdConverter<br/>toProvider: แกะ .Value ออกมา<br/>fromProvider: ห่อกลับเป็น OrderId"]
end
subgraph DB["PostgreSQL ผ่าน Npgsql"]
COL["column Id ชนิด uuid<br/>ค่าดิบล้วน ไม่รู้จัก OrderId"]
end
ID -- "ตอนเขียน (SaveChanges)" --> VC
VC -- "ส่งค่า uuid ลงเก็บ" --> COL
COL -- "ตอนอ่าน (query)" --> VC
VC -- "คืน OrderId ให้ domain" --> ID
คำบรรยายภาพ: ฝั่งซ้ายคือ OrderId ใน domain ที่ห่อ Guid ไว้ ฝั่งขวาคือ column uuid ในฐานข้อมูลที่เก็บค่าดิบล้วน ตรงกลางคือ Value Converter ที่เดินสองทิศทาง: ตอน SaveChanges มันแกะ .Value ออกไปเก็บ ตอน query มันห่อค่ากลับเป็น OrderId ก่อนคืนให้ domain domain ไม่เคยเห็นขั้นตอนนี้เลย และฐานข้อมูลไม่เคยเห็น OrderId เลย
จุดที่ต้องเน้น: converter เปลี่ยนแค่ฝั่ง CLR เท่านั้น ชนิด column ในฐานข้อมูลยังเป็น uuid เหมือนกับตอนใช้ Guid ดิบทุกประการ — ยัง index ได้, ยังใช้เป็น foreign key ได้, ยัง query ด้วย SQL ตรงๆ ได้ ไม่มีตารางเพิ่ม ไม่มี JSON ไม่มี column ข้อความ นักพัฒนาที่เปิดดู schema จะเห็นแค่ column uuid ธรรมดา ส่วน type safety ทั้งหมดอยู่ใน code C# ล้วนๆ
Value Comparer: บอก EF Core ว่า “เท่ากัน” คืออะไร
หัวข้อที่มีชื่อว่า “Value Comparer: บอก EF Core ว่า “เท่ากัน” คืออะไร”Value Converter จัดการเรื่อง อ่าน/เขียน เรียบร้อยแล้ว แต่ EF Core ยังต้องทำอีกงานหนึ่งกับค่าที่แปลง: Change TrackingChange Trackingกลไกของ DbContext ที่จด snapshot ค่าตอน entity ถูกโหลดเข้ามา แล้วเทียบกับค่าปัจจุบันตอน SaveChanges เพื่อสร้าง UPDATE เฉพาะ column ที่เปลี่ยนจริง — มีต้นทุน จึงควรใช้ AsNoTracking() กับ query ที่ไม่ได้ตั้งใจแก้ไขArchitecture — มันเก็บ snapshot ของค่าตอนโหลด entity เข้ามา แล้วเทียบกับค่าปัจจุบันตอน SaveChanges เพื่อสร้าง UPDATE เฉพาะ column ที่เปลี่ยนจริง คำถามคือ “ค่านี้เปลี่ยนไปหรือยัง” ตอบได้ก็ต่อเมื่อ EF Core รู้ 3 อย่าง: จะ เทียบเท่ากัน ยังไง, จะคำนวณ hash ยังไง, และจะ snapshot (deep copy) ยังไง นั่นคือหน้าที่ของ Value Comparer
สำหรับ OrderId ที่เป็น record เรื่องนี้ไม่น่ากังวล — record มี value equality มาให้ในตัว (OrderId a == OrderId b เทียบที่ค่า Value ไม่ใช่ที่ reference) ดังนั้น EF Core สังเคราะห์ comparer ที่ถูกต้องให้ได้เองจาก converter สำหรับ ID แบบนี้ อย่างไรก็ตามปัญหา จะโผล่ทันที เมื่อสิ่งที่แปลงเป็น collection หรือ mutable reference type — เช่น value object ที่ห่อ List ไว้ข้างใน หรือ ID ที่เป็น class ธรรมดาไม่ใช่ record กรณีแบบนั้น EF Core จะตกไปใช้ reference equality เป็นค่าตั้งต้น ซึ่งผิด: change tracking จะเข้าใจว่า “ยังเป็น object เดิม” ทั้งที่ค่าข้างในเปลี่ยนไปแล้ว แล้ว กลืนการอัปเดตหายเงียบๆ
เวลาต้องเขียน comparer เอง หน้าตาจะตรงกับสามคำถามข้างบนพอดี:
// recall: public sealed record OrderId(Guid Value);using Microsoft.EntityFrameworkCore.ChangeTracking;
public sealed class OrderIdComparer : ValueComparer<OrderId>{ public OrderIdComparer() : base( (a, b) => a!.Value == b!.Value, // เทียบเท่ากันยังไง id => id.Value.GetHashCode(), // hash ยังไง id => new OrderId(id.Value)) // snapshot (สำเนา) ยังไง { }}ผูก comparer เข้าไปพร้อม converter ผ่าน HasConversion overload ที่รับสองตัว — ตอนนี้ change tracking ทำงานถูกต้องแม้ในเคสที่ default reference equality เอาไม่อยู่:
order.Property(o => o.Id) .HasConversion(new OrderIdConverter(), new OrderIdComparer()) .HasColumnType("uuid");เก็บหลักคิดนี้ไว้ใช้ต่อทั้งคอร์ส: converter ตอบว่า “เก็บเป็นอะไร” ส่วน comparer ตอบว่า “เปลี่ยนไปหรือยัง” พอถึงบทที่เราเก็บ Money เป็น Owned Entity TypeOwned Entity Typefeature ของ EF Core ที่ map Value Object หรือ Entity ที่ไม่มี identity ของตัวเอง (Address, Money) ให้เป็นส่วนหนึ่งของ owner โดยไม่ต้องมีตารางแยก — วิธีมาตรฐานที่สุดในการเก็บ Value Object ลง SQLArchitecture และเก็บ collection ของ OrderLine comparer จะกลายเป็นตัวชี้ขาดว่า EF Core มองเห็นการเปลี่ยนแปลงจริงหรือมองข้ามมันไป
ทำครั้งเดียว ใช้กับทุกชนิด *Id
หัวข้อที่มีชื่อว่า “ทำครั้งเดียว ใช้กับทุกชนิด *Id”ตอนนี้เรามี converter สำหรับ OrderId แล้ว แต่ model มี strongly-typed ID อยู่สามชนิด — OrderId, OrderLineId, ProductId — และ ProductId กับ OrderLineId ยังโผล่ในหลาย property (เช่น OrderLine.ProductId และ column foreign key ที่ผูก OrderLine กลับไปหา Order) ถ้าต้องเรียก .HasConversion(...) ทุกจุดที่ ID โผล่ ก็จะลืมง่ายและซ้ำซาก
ทางที่สะอาดกว่าคือลงทะเบียน converter ที่ระดับ convention ของ DbContextDbContextจุดเข้าหลักของ EF Core ที่รวม connection, change tracker และ unit of work ไว้ในตัวเดียว มี DbSet<T> ต่อ Aggregate หนึ่งตัว — อายุสั้น (scoped ต่อ request) ไม่ใช่ singleton ที่ใช้ข้าม requestArchitecture ครั้งเดียว แล้วให้มันครอบทุก property ที่เป็นชนิดนั้นทั่วทั้ง model อัตโนมัติ ผ่าน ConfigureConventions:
// recall domain: record OrderId(Guid Value), OrderLineId(Guid Value), ProductId(Guid Value)public sealed class OrderLineIdConverter : ValueConverter<OrderLineId, Guid>{ public OrderLineIdConverter() : base(id => id.Value, v => new OrderLineId(v)) { }}public sealed class ProductIdConverter : ValueConverter<ProductId, Guid>{ public ProductIdConverter() : base(id => id.Value, v => new ProductId(v)) { }}
public sealed class FoodOrderingDbContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { // ลงทะเบียนครั้งเดียว → ทุก property ชนิดนี้ทั่วทั้ง model ถูกแปลงเป็น uuid อัตโนมัติ // (record มี value equality อยู่แล้ว EF Core จึงสังเคราะห์ comparer ให้เองในเคสนี้) configurationBuilder.Properties<OrderId>().HaveConversion<OrderIdConverter>(); configurationBuilder.Properties<OrderLineId>().HaveConversion<OrderLineIdConverter>(); configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>(); }}ผลคือทุกที่ที่ OrderId, OrderLineId หรือ ProductId โผล่เป็น property — ไม่ว่าจะเป็น key หลัก, foreign key หรือ field ธรรมดาใน OrderLine — ล้วนถูกแปลงเป็น column uuid โดยไม่ต้องประกาศซ้ำต่อ property เมื่อ ID มีชนิดใหม่เพิ่มในอนาคต ก็เติมอีกบรรทัดเดียวในที่เดียว ถ้า property ตัวไหน “พิเศษ” (เช่นต้องการ comparer ที่เขียนเองเพราะห่อ collection) ยัง override ที่ระดับ property ด้วย HasConversion(converter, comparer) ใน mapping ของ entity นั้นทับ convention ได้เสมอ
เมื่อจำนวนชนิด ID เยอะขึ้นจนขี้เกียจเขียน converter class ทีละตัว ขั้นถัดไปคือเขียน convention แบบ reflection ตัวเดียวที่สแกนทุก property ใน model มองหาชนิดที่ลงท้ายด้วย Id แล้วห่อ Guid ค่าเดียวไว้ จากนั้นสร้าง converter ให้อัตโนมัติ — แนวคิดเดิมเป๊ะ แค่ตัด boilerplate ต่อชนิดออก แต่สำหรับสามชนิดในคอร์สนี้ การประกาศตรงๆ อ่านง่ายกว่าและเพียงพอแล้ว
record OrderId(Guid Value)ไม่กลายเป็น columnuuidเอง — EF Core มองมันเป็น entity ที่ต้องมี key ของตัวเองแล้วล้ม ต้องใช้ Value Converter (HasConversion) บอกสูตรแปลงไป-กลับกับGuid- Value Comparer บอก EF Core ว่าจะ เทียบเท่ากัน / hash / snapshot ค่าที่แปลงอย่างไร เพื่อให้ change tracking รู้ว่าค่าเปลี่ยนจริง —
recordID ได้ comparer อัตโนมัติ แต่ collection และ mutable type ต้องเขียนเอง ไม่งั้น reference equality จะกลืนการอัปเดตหาย - column ในฐานข้อมูล ยังเป็น
uuidล้วนๆ — converter เปลี่ยนแค่ฝั่ง CLR ไม่แตะ schema, ยัง index/FK/query ได้ตามปกติ - ลงทะเบียน converter ที่
ConfigureConventionsครั้งเดียวต่อชนิด ID แล้วมันครอบทุก property ทั่ว model — domain ยังไม่รู้จัก EF Core แม้แต่บรรทัดเดียว
บทหน้าเราจะขยับจาก “ค่าเดี่ยว” ไปสู่ “ทั้ง aggregate”: เก็บ Order พร้อม _lines ที่ปิดสนิทลง SQL โดยยังรักษา encapsulation ไว้ ด้วย backing field และ constructor binding
เจาะลึกแนวคิดเบื้องหลัง strongly-typed ID ต่อได้ที่คลังอ้างอิง DevIQ:
- Value Object —
OrderIdคือ value object ที่มี identity เป็น “ค่า” ไม่ใช่ reference นี่คือเหตุผลที่มันเทียบเท่ากันที่Valueและทำไมrecordถึงเหมาะกับมัน - Primitive Obsession — การใช้
Guidดิบเป็น id ทุกที่คือ code smell ที่ strongly-typed ID แก้ตรงจุด บทนี้คือวิธีเก็บมันลง SQL โดยไม่ถอยกลับไปหา primitive
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ทำไม record OrderId(Guid Value) ถึงต้องมี Value Converter จึงจะเก็บลง column ได้?