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

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — code บท​นี้​อยู่​ที่ FoodOrdering.Infrastructure/Persistence/Converters/ และ​การ​ลง​ทะเบียน​อยู่​ใน FoodOrderingDbContext

ทวน 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

❌ version ดิบ: ปล่อย​ให้ EF Core เดา​ชนิด OrderId เอง
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 โง่​ลง​ตาม

HasConversion คือ​คำ​ตอบ มัน​บอก EF Core ว่า “property นี้​ใน code เป็น OrderId แต่​ใน​ฐาน​ข้อมูล​ให้​เก็บ​เป็น Guid” พร้อม​สูตร​แปลง​สอง​ทิศทาง: ตอน​เขียน​ให้​แกะ​ค่า Value ออก​มา ตอน​อ่าน​ให้​ห่อ​กลับ​เป็น OrderId เรา​ห่อ​สูตร​นี้​ไว้​ใน class converter ที่ตั้งชื่อชัดๆ เพื่อ​เอา​ไป​ใช้​ซ้ำ​ได้:

Value Converter: OrderId ↔ Guid
// 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 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 เอง หน้าตา​จะ​ตรง​กับ​สาม​คำถาม​ข้าง​บน​พอดี:

Value Comparer: เทียบเท่า / hash / snapshot
// 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 มอง​เห็น​การ​เปลี่ยนแปลง​จริง​หรือ​มอง​ข้าม​มัน​ไป

ตอน​นี้​เรา​มี 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:

convention: converter หนึ่ง​ตัว​ต่อ​ชนิด ID ครอบ​ทั้ง model
// 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) ไม่​กลาย​เป็น column uuid เอง — EF Core มอง​มัน​เป็น entity ที่​ต้อง​มี key ของ​ตัวเอง​แล้ว​ล้ม ต้อง​ใช้ Value Converter (HasConversion) บอก​สูตร​แปลง​ไป-กลับ​กับ Guid
  • Value Comparer บอก EF Core ว่า​จะ เทียบเท่า​กัน / hash / snapshot ค่าที่​แปลง​อย่างไร เพื่อ​ให้ change tracking รู้​ว่า​ค่า​เปลี่ยน​จริง — record ID ได้ 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


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

เจาะ​ลึก​แนวคิด​เบื้องหลัง strongly-typed ID ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Value ObjectOrderId คือ 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 ได้?