ชั้น Infrastructure — EF Core & Repository
บทที่แล้ว PlaceOrderHandler เรียก await _orders.SaveAsync(order, ct) ผ่าน IOrderRepository แล้วก็จบหน้าที่ของมันตรงนั้น — แต่ interface ตัวนั้นยังไม่มีใครรับสายเลยสักตัว มันเป็นแค่สัญญาที่ FoodOrdering.Application ประกาศไว้ลอยๆ ว่า “ฉันต้องการที่จะบันทึกออเดอร์แบบนี้” โดยไม่สนใจว่าใครจะมาเติมของจริงให้ บทนี้เราจะเติมของจริงนั้นด้วย EF Core ใน FoodOrdering.Infrastructure — project ที่ขยับออกจาก Application มาอีกหนึ่งวง และเป็นที่แรก (กับที่เดียว) ในทั้งโซลูชันที่อนุญาตให้พูดถึงตาราง column และ SQL ตรงๆ
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Infrastructure/Persistence/
วงนอกที่มา implement port
หัวข้อที่มีชื่อว่า “วงนอกที่มา implement port”ก่อนเขียน code ใหม่สักบรรทัด ขอทวนสัญญาที่ Application วางไว้ในบทที่แล้วก่อน เพราะนี่คือ “โจทย์” ทั้งหมดที่บทนี้ต้องตอบให้ครบ:
// ทบทวนจากชั้น Application (บทที่ 3) — port ที่ Infrastructure ต้อง implementpublic 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 — adapter ที่คุยกับ EF Core
หัวข้อที่มีชื่อว่า “EfOrderRepository — adapter ที่คุยกับ EF Core”EfOrderRepository เองก็เรียบง่ายพอๆ กับ PlaceOrderHandler ในบทที่แล้ว คือมีหน้าที่แคบและตรงไปตรงมา ไม่มีตรรกะธุรกิจปนอยู่เลยสักบรรทัด:
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 ข้างบนรองรับแค่เส้นทาง “สร้างออเดอร์ใหม่” เพราะ 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 รั่วเข้าไปในสัญญาที่วงในเห็น
DbContext กับการ map Order/OrderLine
หัวข้อที่มีชื่อว่า “DbContext กับการ map Order/OrderLine”EfOrderRepository ต้องพึ่ง FoodOrderingDbContext — จุดเดียวในทั้งโซลูชันที่รู้วิธีแปลง Order/OrderLine/Money ให้กลายเป็นแถวในตาราง:
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"); }); }); }}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 Ignorance — domain ไม่รู้จัก EF
หัวข้อที่มีชื่อว่า “Persistence Ignorance — domain ไม่รู้จัก EF”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:
- Repository — abstraction ของ persistence ที่มี interface คล้าย collection ให้ domain คุยกับข้อมูลโดยไม่รู้จักฐานข้อมูลจริง
- Persistence Ignorance — หลักที่ model domain ไม่ควรรู้ว่าตัวเองถูกบันทึกอย่างไร
- Repositories — คอร์ส DDD Patterns ที่ลงลึกเรื่องกลยุทธ์ persist aggregate เต็มๆ
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3IOrderRepository (interface) กับ EfOrderRepository (implementation) ควรอยู่ที่ project ใดตามลำดับ?