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

ชั้น Infrastructure — EF Core & Repository

บท​ที่​แล้ว PlaceOrderHandler เรียก await _orders.SaveAsync(order, ct) ผ่าน IOrderRepository แล้ว​ก็​จบ​หน้าที่​ของ​มัน​ตรง​นั้น — แต่ interface ตัว​นั้น​ยัง​ไม่มี​ใคร​รับ​สาย​เลย​สัก​ตัว มัน​เป็น​แค่​สัญญา​ที่ FoodOrdering.Application ประกาศไว้ลอยๆ ว่า “ฉัน​ต้องการ​ที่​จะบันทึก​ออเดอร์​แบบ​นี้” โดย​ไม่​สนใจ​ว่า​ใคร​จะ​มา​เติม​ของ​จริง​ให้ บท​นี้​เรา​จะ​เติม​ของ​จริง​นั้น​ด้วย EF Core ใน FoodOrdering.Infrastructure — project ที่​ขยับ​ออก​จาก Application มา​อีก​หนึ่ง​วง และ​เป็น​ที่​แรก (กับ​ที่​เดียว) ใน​ทั้ง​โซลูชัน​ที่​อนุญาต​ให้​พูด​ถึง​ตาราง column และ SQL ตรงๆ

📦 code ตัวอย่าง

code เต็ม​ของ​บท​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — path: FoodOrdering.Infrastructure/Persistence/

ก่อน​เขียน code ใหม่​สัก​บรรทัด ขอ​ทวน​สัญญา​ที่ Application วาง​ไว้​ใน​บท​ที่​แล้ว​ก่อน เพราะ​นี่​คือ “โจทย์” ทั้งหมด​ที่​บท​นี้​ต้อง​ตอบ​ให้​ครบ:

// ทบทวนจากชั้น Application (บทที่ 3) — port ที่ Infrastructure ต้อง implement
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct);
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
}

IOrderRepository เป็น portPortinterface ที่​วงใน​ประกาศ​ไว้​เพื่อ​คุย​กับ​โลก​ภายนอก (เช่น IPaymentGateway) โดย​ไม่รู้จัก implementation จริงArchitecture — interface ที่​วงใน​ประกาศ​ไว้​เพื่อ​คุย​กับ​โลก​ภายนอก โดย​ไม่รู้จัก​เลย​ว่า​ฝั่ง​ตรง​ข้าม​จะ​เป็น​ฐาน​ข้อมูล​อะไร หน้าที่​ของ​บท​นี้​คือ​เขียน adapterAdaptercode วงนอก​ที่ implement port เพื่อ​เชื่อม​กับ​เทคโนโลยี​จริง เช่น EF Core repository หรือ HTTP payment clientArchitecture ตัว​หนึ่ง​ชื่อ EfOrderRepository ที่​ยืน​อยู่​ใน FoodOrdering.Infrastructure แล้ว implement interface นี้​ด้วย EF Core จริงๆ ProjectReference ของ Infrastructure ชี้​เข้าหา Application (เพื่อ​รู้จัก IOrderRepository ที่​จะ implement) ไม่ใช่​กลับ​กัน — Application ไม่มี​วัน​รู้จัก​คำ​ว่า EfOrderRepository เลย​แม้แต่​ชื่อ​เดียว

flowchart LR
  Infra["FoodOrdering.Infrastructure<br/>EfOrderRepository (implementation)"] --> App["FoodOrdering.Application<br/>IOrderRepository «interface»"]
  classDef inner fill:#16a34a,stroke:#065f46,color:#f8fafc;
  class App inner;

คำ​บรรยาย​ภาพ: ลูกศร​คือ​ทิศทาง ProjectReference — Infrastructure (วงนอก) รู้จัก Application (วงใน) เพื่อ implement interface ที่ Application ประกาศ​ไว้ ไม่มี​ลูกศร​วิ่ง​ย้อน​กลับ Application จึง​ไม่มี​วัน​ต้อง reference EF Core เลย

EfOrderRepository เอง​ก็​เรียบ​ง่ายพอๆ กับ PlaceOrderHandler ใน​บท​ที่​แล้ว คือ​มีหน้าที่​แคบ​และ​ตรง​ไป​ตรง​มา ไม่มี​ตรรกะ​ธุรกิจ​ปน​อยู่​เลย​สัก​บรรทัด:

FoodOrdering.Infrastructure/Persistence/EfOrderRepository.cs
public sealed class EfOrderRepository : IOrderRepository
{
private readonly FoodOrderingDbContext _db;
public EfOrderRepository(FoodOrderingDbContext db) => _db = db;
public async Task SaveAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
}
public async Task<Order?> FindAsync(OrderId id, CancellationToken ct) =>
await _db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
ขอบเขต​ของ SaveAsync

SaveAsync ข้าง​บน​รองรับ​แค่​เส้นทาง “สร้าง​ออเดอร์​ใหม่” เพราะ use case เดียว​ที่​มี​อยู่​ตอน​นี้​คือ PlaceOrder ซึ่ง​สร้าง Order ขึ้น​มา​ใหม่​เสมอ ยัง​ไม่มี use case ไหน​ที่​โหลด​ออเดอร์​เก่า​มา​แก้ไข — เรื่อง​ยกเลิก/คืน​เงิน​ที่​ต้อง Update แทน Add เป็น​ของ​เคส​ซับซ้อน C1–C3 ที่​คอร์ส​นี้​จะ​กลับ​มา​แตะ​อีก​ครั้ง​ช่วง​ท้าย

สังเกต​ว่า EfOrderRepository ไม่​ได้​แค่ implement interface เฉยๆ แต่​มัน​คือ​ตัวอย่าง​ของ repositoryRepositoryabstraction ที่​ทำให้ domain เข้าถึง/บันทึก aggregate ได้​เหมือน​เป็น in-memory collection โดย​ไม่รู้จัก​เทคโนโลยี​จัด​เก็บ​จริง — interface อยู่​วงใน implementation อยู่ InfrastructureTactical Design ตาม​นิยาม​ดั้งเดิม — abstraction ที่​ทำให้ code ฝั่ง Application รู้สึก​เหมือน​กำลัง​คุย​กับ collection ใน​หน่วย​ความ​จำ (SaveAsync คล้าย Add, FindAsync คล้าย Find) ทั้ง​ที่​เบื้องหลัง​มัน​คือ round-trip ไป​ฐาน​ข้อมูล​จริง สังเกต​ต่อ​ไป​อีกว่า interface นี้ ไม่มี method แบบ IQueryable<Order> Query() หรือ​อะไร​ทำนอง​นั้น​เลย — ถ้า​มี Application จะ​เริ่ม​เขียน LINQ ที่ EF Core แปล​เป็น SQL เอง ซึ่ง​เท่ากับ​ให้​วงใน​รู้จัก​วิธี query ของ EF Core ทาง​อ้อม การ​จำกัด interface ให้​เหลือ​แค่2 method แคบๆ อย่าง SaveAsync/FindAsync จึง​ไม่ใช่​ความ​ขี้เกียจ แต่​เป็นการ​รักษา​เส้น​แบ่ง​ไม่​ให้​รายละเอียด​ของ EF Core รั่ว​เข้าไป​ใน​สัญญา​ที่​วงใน​เห็น

EfOrderRepository ต้อง​พึ่ง FoodOrderingDbContext — จุด​เดียว​ใน​ทั้ง​โซลูชัน​ที่​รู้​วิธี​แปลง Order/OrderLine/Money ให้​กลาย​เป็น​แถว​ใน​ตาราง:

FoodOrdering.Infrastructure/Persistence/FoodOrderingDbContext.cs
public sealed class FoodOrderingDbContext : DbContext
{
public FoodOrderingDbContext(DbContextOptions<FoodOrderingDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(order =>
{
order.ToTable("Orders");
order.HasKey(o => o.Id);
order.Property(o => o.Id)
.HasConversion(id => id.Value, value => new OrderId(value));
order.OwnsOne(o => o.Total, total =>
{
total.Property(m => m.Amount).HasColumnName("TotalAmount");
total.Property(m => m.Currency).HasColumnName("TotalCurrency");
});
order.HasMany(o => o.Lines)
.WithOne()
.HasForeignKey("OrderId");
});
modelBuilder.Entity<OrderLine>(line =>
{
line.ToTable("OrderLines");
line.HasKey(l => l.Id);
line.Property(l => l.Id)
.HasConversion(id => id.Value, value => new OrderLineId(value));
line.Property(l => l.ProductId)
.HasConversion(id => id.Value, value => new ProductId(value));
line.Property(l => l.Quantity)
.HasConversion(q => q.Value, value => new Quantity(value));
line.OwnsOne(l => l.UnitPrice, price =>
{
price.Property(m => m.Amount).HasColumnName("UnitPriceAmount");
price.Property(m => m.Currency).HasColumnName("UnitPriceCurrency");
});
});
}
}
Total เป็น​ค่าที่​คำนวณ ไม่ใช่ column อิสระ

order.OwnsOne(o => o.Total, ...) ไม่​ได้​แปล​ว่า Total เป็น field ที่​ใคร​ก็ set ตรงๆ ได้ — จาก​บท​ที่ 2 Total ถูกคำนวณจาก _lines เสมอ​ตอน constructor ทำงาน (invariant ②) OwnsOne แค่​สอน EF Core ว่า​จะ​เอา​ค่าที่​คำนวณ​เสร็จ​แล้ว​นั้น​ไป​ฝัง​เป็น column TotalAmount/TotalCurrency ของ​ตัวเอง​ใน​ตาราง Orders ตอน​บันทึก​เท่านั้น ส่วน​ตอน​อ่าน​กลับ EF Core ยัง​ต้อง​มี​ทาง​ผูก Lines เข้า​กับ constructor ให้ Total คำนวณ​ซ้ำ​ได้​ถูกต้อง​ด้วย (รายละเอียด​อยู่​ใน​หัวข้อ​ถัด​ไป และ code เต็ม​ใน​ตัวอย่าง repo)

ทุก​บรรทัด​ใน​นี้​คือ “งาน​แปล​ภาษา” ล้วนๆ ไม่มี​กฎ​ธุรกิจ​ปน​อยู่​เลย: HasConversion สอน EF ว่า OrderId/OrderLineId/ProductId/Quantity แต่ละ​ตัว​คือ value object ที่​ห่อ​ค่า​ดิบ​ไว้​ชั้น​เดียว (แกะ​ออก​ตอน​เขียน ห่อ​กลับ​ตอน​อ่าน) ส่วน OwnsOne สอน​ว่า Money ไม่ใช่​ตาราง​ของ​ตัวเอง แต่​เป็นกลุ่ม column ที่​ฝัง​อยู่​ใน​ตาราง​เจ้าของ (TotalAmount/TotalCurrency อยู่​ใน​แถว​เดียว​กับ Orders) ตรง​ตาม​ที่ Money ถูก​ออกแบบ​ไว้​ตั้งแต่​บท​ที่ 2 ว่า​เป็น value object ที่​ไม่มี identity เป็น​ของ​ตัวเอง

จุด​ที่​น่า​สังเกต​ที่สุด​คือ order.HasMany(o => o.Lines)Lines เป็น​แค่ IReadOnlyCollection<OrderLine> คำนวณ​จาก field ส่วนตัว _lines เท่านั้น (public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); จาก​บท​ที่ 2) EF Core หา _lines เจอ​เอง​ตาม​ธรรมเนียม​การ​ตั้ง​ชื่อ backing field โดย​ไม่​ต้อง​มี​ใคร​ไป​เปิด field นั้น​เป็น public หรือ​เพิ่ม setter ให้ Lines เลย และ​ตอน​โหลด​ข้อมูล​กลับ​มา EF Core ก็​สร้าง Order ผ่าน private Order(OrderId id, IEnumerable<OrderLine> lines) ตัว​เดิม​จาก​บท​ที่ 2 ได้ตรงๆ (EF Core ผูก​กับ constructor แบบ​มี parameter ได้​ตั้งแต่ version 3.0 โดย​จับ​คู่​ชื่อ parameter กับ property) — Order จึง​ไม่​ต้อง​แก้​แม้แต่​บรรทัด​เดียว​เพื่อ​ให้ EF Core อ่าน/เขียน​มัน​ได้ นี่​คือ persistence ignorance ที่​จับ​ต้อง​ได้ ไม่ใช่​แค่คำสวยๆ

persistence ignorancePersistence Ignoranceหลัก​ที่​ว่า domain model ไม่​ควร​รู้​อะไร​เลย​เกี่ยว​กับ​วิธี​ที่​มัน​ถูก​บันทึก​ลง​ฐาน​ข้อมูล ทำให้​สลับ ORM/DB ได้​โดย​ไม่​แตะ​กฎ​ธุรกิจArchitecture คือ​หลัก​ที่​ว่า domain ไม่​ควร​รู้​อะไร​เลย​เกี่ยว​กับ​วิธี​ที่​ตัวเอง​ถูก​บันทึก ลอง​ไล่​ดู​ว่า .csproj ของ FoodOrdering.Domain (จาก​บท​ที่ 1) มี <ProjectReference> หรือ <PackageReference> ไปหา Microsoft.EntityFrameworkCore ไหม — คำ​ตอบ​คือ​ไม่มี​เลย และ​ห้าม​มี​ด้วย ถ้า​วัน​ไหน Order ต้อง​แปะ [Key], [Column], หรือ attribute ของ EF Core สัก​ตัว​เพื่อ​ให้ mapping ทำงาน นั่น​คือ​สัญญาณ​ว่า Dependency Rule กำลัง​ถูก​ละเมิด เพราะ Domain จะ​ต้อง reference package ของ EF Core เพียง​เพื่อ​จะ​ประกาศ attribute พวก​นั้น

นี่​คือ​เหตุผล​ที่ OnModelCreating ทั้งหมด​ข้าง​บน​เขียน​ด้วย Fluent API ใน file FoodOrderingDbContext.cs ที่​อยู่​ฝั่ง Infrastructure แทนที่​จะ​แปะ attribute ลงบน Order/OrderLine/Money โดยตรง งาน mapping ทุก​อย่าง — ชื่อ​ตาราง ชื่อ column การ​แปลง value object เป็น column ดิบ ความ​สัมพันธ์​ระหว่าง Order กับ OrderLine — ถูกกัน​ไว้​ใน file เดียว​ที่ project เดียว พูด​ให้​ชัด​กว่า​นั้น: SQL ทั้งหมด​ใน​ทั้ง​โซลูชัน​จำกัด​อยู่​ที่ FoodOrdering.Infrastructure เท่านั้น ไม่มี raw SQL, ไม่มี attribute ของ EF Core และ​ไม่มี​แม้แต่​คำ​ว่า DbContext โผล่​ใน project อื่น​เลย​สัก​ที่ ผล​ที่​ได้​คือ​ถ้า​วัน​หนึ่ง​ทีม​ตัดสิน​ใจ​เปลี่ยน​จาก EF Core ไป​เป็น Dapper หรือ​สลับ​จาก PostgreSQL ไป SQL Server ก็มี file ให้​แก้​อยู่​จุด​เดียว​คือ​ใน​วง​นี้ — Order.Place(...), PlaceOrderHandler, และ IOrderRepository ไม่​ต้อง​ขยับ​แม้แต่​บรรทัด​เดียว

การ​ลง​ทะเบียน EfOrderRepository ให้​เป็น​ของ​จริง​ที่​ตอบ​สนอง IOrderRepository ก็​เกิด​ขึ้น​นอก​ทั้ง​สองวง​นี้​อีก​ที — ผ่าน builder.Services.AddScoped<IOrderRepository, EfOrderRepository>() ที่ composition root (FoodOrdering.Web/Program.cs) ซึ่ง​เป็น dependency injectionDependency Injectionเทคนิค​ส่ง dependency (ที่ implement ตาม interface) เข้า​มา​จาก​ภายนอก​แทนที่​จะ​สร้าง​เอง ทำให้​วงใน​พึ่งพา abstraction ไม่ใช่​ของ​จริง และ​สลับ/ทดสอบ​ได้​ง่ายArchitecture ที่​ทำให้​ทุก​อย่าง​มา​บรรจบ​กัน​ตอน app ส​ตาร์ต​จริง โดยที่​ทั้ง Application และ Domain ไม่รู้​ด้วย​ซ้ำ​ว่า​มี​การ​ลง​ทะเบียน​แบบ​นี้​เกิด​ขึ้น

บท​นี้​เรา​เติม​ร่าง​ให้ IOrderRepository ที่ Application ประกาศไว้ลอยๆ ใน​บท​ที่​แล้ว: EfOrderRepository เป็น adapter ที่ implement interface นั้น​ด้วย2 method แคบๆ (SaveAsync/FindAsync) ตาม​นิยาม​ของ Repository pattern ที่​ทำให้ Application คุย​กับ​ข้อมูล​ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ, FoodOrderingDbContext เก็บ mapping ทั้งหมด​ของ Order/OrderLine/Money ไว้​ด้วย Fluent API แทน attribute เพื่อ​ไม่​ให้ Domain ต้อง​รู้จัก EF Core, และ​สุดท้าย​คือ​หลัก persistence ignorance ที่​ยืนยัน​ว่า SQL ทั้งหมด​จำกัด​อยู่​ใน​วง​นี้​วง​เดียว — Order ยัง​คง​เป็น Order ตัว​เดิม​จาก​บท​ที่ 2 ทุก​ประการ ไม่มี​ร่องรอย​ของ​ฐาน​ข้อมูล​ปน​เปื้อน​เข้าไป​แม้แต่​นิดเดียว

บท​ถัด​ไป​เรา​จะ​ขยับ​ออก​มา​วงนอก​สุด สร้าง FoodOrdering.Web ที่​เปิด API endpoint จริง​ให้ PlaceOrder ถูก​เรียก​ผ่าน HTTP ได้ พร้อม​ประกอบ​ทุก​ชิ้น​ที่​สร้าง​มา​ตลอด​สาม​บท​ที่​ผ่าน​มา​เข้า​ด้วย​กัน​ที่ composition root


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

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • Repository — abstraction ของ persistence ที่​มี interface คล้าย collection ให้ domain คุย​กับ​ข้อมูล​โดย​ไม่รู้จัก​ฐาน​ข้อมูล​จริง
  • Persistence Ignorance — หลัก​ที่ model domain ไม่​ควร​รู้​ว่า​ตัวเอง​ถูก​บันทึก​อย่างไร
  • Repositories — คอร์ส DDD Patterns ที่​ลง​ลึก​เรื่อง​กลยุทธ์ persist aggregate เต็มๆ

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

IOrderRepository (interface) กับ EfOrderRepository (implementation) ควรอยู่ที่ project ใดตามลำดับ?