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

เมื่อ EF Core ไม่ใช่​คำ​ตอบ

เจ็ด​บท​ที่​ผ่าน​มา​คือ​คำ​แก้​ต่าง​ให้ EF Core: map Order ทั้ง​ก้อน​ลง PostgreSQL อย่าง​ซื่อสัตย์​ต่อ model, ต้าน convention ที่​ลาก model กลับ​ไป​กลวง, ปิด​ช่องว่าง​วัตถุ​กับ​ตาราง​โดย​ไม่​แตะ domain แม้แต่​บรรทัด​เดียว บท​ปิด​นี้​กลับ​ด้าน — ไม่ใช่​เพื่อ​ล้ม​สิ่ง​ที่​สร้าง​มา แต่​เพื่อ​วาง​มัน​ให้​ถูก​ที่: EF Core เป็น​เครื่องมือ ไม่ใช่​ศาสนา มี​เส้นทาง​ที่​มัน​เริ่ม​ฝืด มี​ทาง​เลือก​ที่​เข้าท่า​กว่า​ใน​บาง​งาน และ​มี​คำถาม​สำคัญ​กว่า​ว่า “จะ​ใช้ ORM ไหน” คือ “สถาปัตยกรรม​ของ​เรา​สลับ ORM ได้​โดย​ไม่​สะเทือน domain หรือ​เปล่า”

คำ​ตอบ​ของ​คำถาม​หลัง​นั้น​ถูกวาง​ไว้​ตั้งแต่​ต้น​คอร์ส​แล้ว — มัน​คือ RepositoryRepositoryตัว​ที่​ทำให้ domain เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด EF Core ไว้​ทั้งหมด — implementation จริง​มัก​เป็น​แค่ wrapper บาง ๆ รอบ DbSet<Order> เพราะ DbContext เป็น unit of work อยู่​แล้ว​ใน​ตัวTactical Design ที่​คั่น​อยู่​เหนือ DbContextDbContextจุด​เข้า​หลัก​ของ EF Core ที่​รวม connection, change tracker และ unit of work ไว้​ใน​ตัว​เดียว มี DbSet<T> ต่อ Aggregate หนึ่ง​ตัว — อายุ​สั้น (scoped ต่อ request) ไม่ใช่ singleton ที่​ใช้​ข้าม requestArchitecture บท​นี้​จะ​แสดง​ว่า seam เส้น​นี้​แหละ​ที่​ทำให้​เรา​หล่น​ไป Dapper สำหรับ​เส้นทาง​อ่าน​ที่​ร้อน​จัด หรือ​ย้าย​ไป Marten แบบ document store ได้ โดยที่ Order ยัง​เป็น​ก้อน​เดิม​ที่รักษากฎ​ของ​ตัวเอง​ไว้​ไม่​เปลี่ยน

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — code adapter ทาง​เลือก​ใน​บท​นี้​อยู่​ที่ FoodOrdering.Infrastructure/Persistence/ (EF Core, Marten) และ​ฝั่ง​อ่าน​ด้วย Dapper อยู่​ที่ FoodOrdering.Infrastructure/Queries/ ส่วน port IOrderRepository อยู่​ใน domain ที่ FoodOrdering.Domain/Orders/

นี่​คือ​รูป model ที่​ทั้ง​คอร์ส​เก็บ​ลง SQL มา​ตลอด — ตัว​เดิม ไม่​แตะ​แม้แต่​บรรทัด​เดียว (ทวน​จาก Course A บท​ที่ 2 + Course B บท​ที่ 2–3 — OrderLine เริ่ม​เป็น record ใน Course A บท 2 แล้ว​ถูก​ยก​ระดับ​เป็น Entity<OrderLineId> ใน Course B บท 3; นี่​คือ​รูป​สุดท้าย​ที่​เรา map):

// FoodOrdering.Domain/Orders/
public interface IDomainEvent { }
public sealed record OrderId(Guid Value);
public sealed record OrderLineId(Guid Value); // ประกาศก่อนใช้เป็น key ของ OrderLine
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 enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public sealed class OrderLine : Entity<OrderLineId> // entity class ไม่ใช่ record แล้ว
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
// ...
}
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
private readonly List<IDomainEvent> _domainEvents = new();
public OrderId Id { get; } // strongly-typed id
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } // คำนวณ ไม่ใช่ set อิสระ
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private Order(OrderId id, IEnumerable<OrderLine> lines) { /* คำนวณ Total, รักษา invariant */ }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) // ประตูเดียว
{
if (items.Count == 0)
throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
// ... รักษา invariant, คำนวณ Total
}
}

ทบทวน​สิ่ง​ที่ EF Core ให้​เรา​ฟรี​ตลอด​คอร์ส: Change TrackingChange Trackingกลไก​ของ DbContext ที่​จด snapshot ค่า​ตอน entity ถูก​โหลด​เข้า​มา แล้ว​เทียบ​กับ​ค่า​ปัจจุบัน​ตอน SaveChanges เพื่อ​สร้าง UPDATE เฉพาะ column ที่​เปลี่ยน​จริง — มี​ต้นทุน จึง​ควร​ใช้ AsNoTracking() กับ query ที่​ไม่​ได้​ตั้งใจ​แก้ไขArchitecture ที่ snapshot ทุก entity แล้ว​สร้าง UPDATE เฉพาะ column ที่​เปลี่ยน, unit of work ที่​รวบ​ทุก​อย่าง​ไว้​ใน SaveChanges เดียว, การ rehydrate Order ทั้ง​ก้อน​ผ่าน private constructor โดย​รักษา encapsulation ทั้งหมด​นี้​คือ​มูลค่า​จริง — แต่​มัน​คือ​มูลค่า​ของ ฝั่ง​เขียน ที่​เรา​ต้องการ​โหลด aggregate มา​เรียก method แล้ว​เซฟกลับ

พอ​ข้าม​มา​ฝั่ง​อ่าน​ที่​ร้อน​จัด สมการ​เปลี่ยน บท​ที่ 7 แสดง​แล้ว​ว่า ProjectionProjectionการ query โดย​เลือก​เฉพาะ field ที่​ต้อง​ใช้ (Select ไป​เป็น DTO) แทนที่​จะ​โหลด​ทั้ง Aggregate ผ่าน change tracking — เร็ว​กว่า​และ​ไม่​ผูก​กับ entity graph ทั้ง​ก้อน เหมาะ​กับ use case ที่​แค่ 'อ่าน' เช่น​หน้า​รายการออเดอร์Architecture ผ่าน Select ยิง​ตรง​เข้า DTO เร็ว​กว่า​ลาก aggregate มา​ทั้ง​ก้อน​เยอะ — และ​สำหรับ​งาน​อ่าน​ส่วน​ใหญ่ projection ของ EF Core เพียงพอ​และ​ควร​เป็น​ตัว​เลือก​แรก แต่​มี​เส้นทาง​ที่​มัน​เริ่ม​ฝืด​จริง: รายงาน​ที่ join ห้า​ตาราง​พร้อม window function, query ที่​ต้อง​ใช้ CTE ซ้อน​หรือ lateral join ของ PostgreSQL, หรือ endpoint ที่​ถูก​ยิง​หลัก​หมื่น​ครั้ง​ต่อ​วินาที​จน overhead ของ​การ​แปล LINQ เป็น SQL และ​การ materialize object เริ่ม​มี​น้ำหนัก จุด​นั้น​เอง​ที่ EF Core หยุด​เป็น​คำ​ตอบ​ที่​ดี​ที่สุด — ไม่ใช่​เพราะ​มัน​พัง แต่​เพราะ​มัน​ทำงาน​เกิน​สิ่ง​ที่​งาน​นี้​ต้องการ

แต่​ก่อน​จะ​หยิบ​เครื่องมือ​อื่น มี​กับดัก​ที่​ต้อง​เลี่ยง: การ​หล่น​ไป raw SQL ไม่​ได้​แปล​ว่าเอา NpgsqlConnection ไป​โปรย​ไว้​กลาง​ตรรกะ​ธุรกิจ นี่​คือ​วิธี​ที่​ผิด —

❌ version ดิบ: ยัด raw SQL ปน​เข้าไป​ใน code ที่​ควร​รู้จัก​แค่ domain
// อยู่กลาง handler/domain — ผูกตายกับ Npgsql, สลับ persistence ไม่ได้เลย
public async Task<decimal> GetOrderTotalAsync(Guid orderId)
{
await using var conn = new NpgsqlConnection("Host=localhost;Database=foodordering");
// SQL ดิบโผล่มากลางตรรกะธุรกิจ: domain ไม่ได้รู้จักแค่ตัวเองอีกต่อไป มันรู้จัก schema, รู้จัก Npgsql
// และทดสอบทีก็ต้องมี Postgres จริงทุกครั้ง แม้แต่ test ที่ไม่เกี่ยวกับฐานข้อมูล
return await conn.ExecuteScalarAsync<decimal>(
"SELECT \"TotalAmount\" FROM \"Orders\" WHERE \"Id\" = @id", new { id = orderId });
}

ปัญหา​ไม่ใช่ “ใช้ raw SQL” — ปัญหา​คือ raw SQL รั่ว​เข้าไป​ใน​ที่​ที่​ควร​รู้จัก​แค่ domain ทำให้ Persistence Ignorance ที่​คอร์ส​นี้​รักษา​มา​ทั้ง​เจ็ด​บท​พัง​ใน​บรรทัด​เดียว ทางออก​ที่​ถูก​คือ​หล่น​ไป Dapper หลัง seam ไม่ใช่​ทะลุ​มัน​ออก​มา

Dapper คือ micro-ORM บางๆ ที่​ทำงาน​เดียว: รับ SQL ที่​เรา​เขียน​เอง ยิง​ผ่าน IDbConnection แล้ว map ผลลัพธ์​เข้า object ให้ — ไม่มี change tracking, ไม่มี unit of work, ไม่มี​การ​แปล LINQ ทุก​อย่าง​ที่ EF Core ทำให้​เรา​คือ​สิ่ง​ที่ Dapper ไม่​ทำ และ​นั่น​คือ​จุด​ขาย​ของ​มัน​บน​เส้นทาง​อ่าน: overhead น้อย​ที่สุด กับ​การ​ควบคุม SQL ได้​ทุก​ตัว​อักษร เหมาะ​กับ read model ที่ query ซับซ้อน​หรือ​ถูก​ยิง​หนัก​จน​ต้อง​รีด​ทุก​มิลลิ​วินาที

code ฝั่ง​อ่าน​ด้วย Dapper อยู่​แยก​จาก IOrderRepository โดย​ตั้งใจ — มัน​คืน​ค่า DTO แบนๆ ไม่ใช่ Order aggregate และ​ไม่​ต้อง​ผ่าน DbContext เลย:

FoodOrdering.Infrastructure/Queries/DapperOrderSummaries.cs
using Dapper;
using Npgsql;
// DTO สำหรับหน้ารายการ — ไม่ใช่ entity ไม่มี identity ไม่มี behavior
public sealed record OrderSummaryDto(Guid OrderId, int LineCount, decimal TotalAmount, string Currency);
public sealed class DapperOrderSummaries
{
private readonly string _connectionString;
public DapperOrderSummaries(string connectionString) => _connectionString = connectionString;
public async Task<IReadOnlyList<OrderSummaryDto>> ListAsync(CancellationToken ct)
{
// SQL เขียนเอง คุมได้ทุกตัวอักษร — column ตรงกับ mapping จาก Course A บท 4
// (Money ฝังเป็น TotalAmount/TotalCurrency, OrderLines มี FK OrderId)
const string sql = """
SELECT o."Id" AS OrderId,
COUNT(l."Id") AS LineCount,
o."TotalAmount" AS TotalAmount,
o."TotalCurrency" AS Currency
FROM "Orders" o
LEFT JOIN "OrderLines" l ON l."OrderId" = o."Id"
GROUP BY o."Id", o."TotalAmount", o."TotalCurrency"
ORDER BY o."Id";
""";
await using var conn = new NpgsqlConnection(_connectionString);
var rows = await conn.QueryAsync<OrderSummaryDto>(new CommandDefinition(sql, cancellationToken: ct));
return rows.ToList();
}
}

สังเกต​เส้น​แบ่ง​ให้​ดี: Dapper ทำงาน​ฝั่ง อ่าน​อย่าง​เดียว ที่​ผลลัพธ์​เป็น DTO — มัน​ไม่​แตะ Order.Place(...), ไม่​รักษา invariant, ไม่มี​ความคิด​เรื่อง aggregate เลย และ​นั่น​ถูกต้อง​แล้ว เพราะ​ฝั่ง​อ่าน​ไม่​ต้องการ​สิ่ง​เหล่า​นั้น กฎ​ง่ายๆ คือ: ฝั่ง​เขียน​ที่​ต้อง​รักษา​กฎ ให้ EF Core map aggregate ทั้ง​ก้อน; ฝั่ง​อ่าน​ที่​ร้อน​จัด​และ​ซับซ้อน หล่น​ไป Dapper ได้ ทั้ง​คู่​ยิง​ไป PostgreSQL ตัว​เดียวกัน​ผ่าน Npgsql เหมือน​กัน ต่าง​แค่​ชั้น​ที่​คั่น​อยู่

บาง​ครั้ง​คำถาม​ไม่ใช่ “ORM ไหน” แต่​เป็น “รูปแบบ​การ​เก็บ​แบบ​ไหน” EF Core map graph วัตถุ​ลง ตาราง​เชิง​สัมพันธ์Order หนึ่ง​ใบ​กระจาย​เป็น​แถว​ใน Orders บวก​หลาย​แถว​ใน OrderLines แต่​ถ้า​มอง​อีก​มุม Order ทั้ง​ก้อน​คือ หนึ่ง​เอกสาร: root กับ​ทุก OrderLine ข้าง​ใน​ถูก​อ่าน​และ​เขียน​พร้อม​กัน​เป็น​หน่วย​เดียว​เสมอ​อยู่​แล้ว (นั่น​คือ​นิยาม​ของ aggregate) การ​เก็บ​มัน​เป็น​เอกสาร JSON ก้อน​เดียว​จึง​เข้า​รูป​กับ AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถือ​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง มี root เดียว​เป็น​ประตู​เข้า​และ​เป็น​หน่วย​ของ transaction เช่น Order ที่​คุม OrderLine — งาน​ของ​บท​นี้​คือ map Aggregate ทั้ง​ก้อน​ลง SQL โดย​ไม่​ทำลาย invariant ที่ root รักษา​ไว้Tactical Design อย่าง​เป็น​ธรรมชาติ ไม่​ต้อง​ประกอบ​ร่าง​จาก​หลาย​ตาราง​ทุก​ครั้ง​ที่​โหลด

Marten คือ library ที่​ทำ​แบบ​นั้น​บน PostgreSQL — มัน​ใช้​ความ​สามารถ JSONB ของ Postgres เก็บ Order ทั้ง​ก้อน​เป็น​เอกสาร​เดียว โดย​ไม่​ต้อง​เขียน mapping แบบ relational เลย และ​ยัง​ต่อยอด​เป็น event sourcing ได้​ใน​ตัว จุด​ที่ Marten เข้าท่า​กว่า EF Core คือ​เมื่อ aggregate ซ้อน​ลึก, schema เปลี่ยน​บ่อย​จน​การ​ทำ migration เชิง​สัมพันธ์​เจ็บ​ปวด หรือ​เรา​อยาก​ได้ event store จริงๆ ไม่ใช่​แค่​ตาราง state:

FoodOrdering.Infrastructure/Persistence/MartenOrderRepository.cs
using Marten;
// adapter ตัวเดียวกับ port IOrderRepository เป๊ะ — แต่เก็บ Order เป็นเอกสาร JSONB
public sealed class MartenOrderRepository : IOrderRepository
{
private readonly IDocumentSession _session;
public MartenOrderRepository(IDocumentSession session) => _session = session;
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) =>
_session.LoadAsync<Order>(id.Value, ct); // โหลดเอกสารทั้งก้อนกลับเป็น Order
public async Task SaveAsync(Order order, CancellationToken ct)
{
_session.Store(order); // เก็บ Order ทั้งใบเป็นเอกสารเดียว
await _session.SaveChangesAsync(ct); // commit — unit of work ของ Marten เอง
}
}

Marten ไม่​ได้​ดี​กว่า​หรือ​แย่​กว่า EF Core ใน​ทุก​มิติ — มัน​แค่​ตอบ​โจทย์​คนละ​แบบ ประเด็น​ของ​บท​นี้​ไม่ใช่ “ย้าย​ไป Marten เถอะ” แต่​คือ: การ​เลือก​กลไก​เก็บ​ข้อมูล​ควร​เป็นการ​ตัดสิน​ใจ​ที่​เปลี่ยน​ได้ ไม่ใช่​สิ่ง​ที่​ผูก​ตาย​กับ domain ตั้งแต่​วัน​แรก และ​สิ่ง​ที่​ทำให้​มัน​เปลี่ยน​ได้​คือ seam เส้น​เดียว​ที่​เรา​จะ​พูด​ถึง​ต่อ​ไป

ทำไม​ทั้ง3 adapter ข้าง​บน — EF Core, Dapper, Marten — ถึง​สลับ​กัน​ได้​โดย domain ไม่​ต้อง​รู้เรื่อง​เลย เพราะ domain กับ application ไม่​เคย​เห็น DbContext, ไม่​เคย​เห็น NpgsqlConnection, ไม่​เคย​เห็น IDocumentSession มัน​เห็น​แค่ port เดียว​ที่​ประกาศ​ไว้​ใน domain:

// FoodOrdering.Domain/Orders/IOrderRepository.cs — port ที่ domain/application รู้จัก
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}

port นี้​พูด​ภาษา domain ล้วนๆ — รับ​และ​คืน Order กับ OrderId ไม่มี​คำ​ว่า SQL, JSONB หรือ SaveChanges โผล่​มา​เลย ฝั่ง Infrastructure จึง​มี adapter ได้​หลาย​ตัว​ที่​ทำ​สัญญา​เดียวกัน​นี้​ให้​เป็น​จริง ตัว default ของ​คอร์ส​คือ EF Core ที่​บาง​เท่า​เดิม​จาก​บท 6 เพราะ DbContext เป็น unit of work ให้​อยู่​แล้ว:

FoodOrdering.Infrastructure/Persistence/EfOrderRepository.cs
using Microsoft.EntityFrameworkCore;
public sealed class EfOrderRepository : IOrderRepository
{
private readonly FoodOrderingDbContext _db; // DbContext ก้อนเดียวจาก Course A บท 4
public EfOrderRepository(FoodOrderingDbContext db) => _db = db;
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) =>
_db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task SaveAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct); // SaveChanges เดียว = 1 transaction
}
}

การ​สลับ​จึง​เกิด​ที่ composition root จุด​เดียว — บรรทัด​ลง​ทะเบียน DI ใน Program.cs — โดยที่ Order, IOrderRepository และ handler ทุก​ตัว​ที่ inject port นี้​เข้าไป ไม่มี​ตัว​ไหน​ต้อง​แก้​แม้แต่​ตัว​อักษร​เดียว:

// Program.cs — เลือก adapter ที่จุดเดียว domain ไม่รู้เรื่องด้วยซ้ำ
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
// วันหนึ่งย้ายไป document store ก็แค่เปลี่ยนบรรทัดเดียว:
// builder.Services.AddScoped<IOrderRepository, MartenOrderRepository>();

นี่​คือ​มูลค่า​จริง​ของ seam: มัน​เปลี่ยน “เลือก ORM ผิด​แล้ว​ต้อง​รื้อ​ทั้ง​ระบบ” ให้​กลาย​เป็น “เลือก ORM ผิด​แล้ว​แก้​บรรทัด​เดียว” การ​ตัดสิน​ใจ​เรื่อง persistence จึง​กลับ​ด้าน​ได้ (reversible) แทนที่​จะ​เป็น​พันธนาการ​ถาวร — ซึ่ง​คือ​เหตุผล​เชิง​ปฏิบัติ​ที่แท้​จริง​ว่า​ทำไม​ถึงกัน domain ออก​จาก EF Core มา​ตั้งแต่​บท​แรก

flowchart TB
  subgraph DOM["domain + application — รู้จักแค่ port"]
    APP["PlaceOrderHandler<br/>Order.Place(...) แล้ว repo.SaveAsync(order)"]
    PORT["IOrderRepository (seam)<br/>FindAsync • SaveAsync<br/>พูดภาษา domain ล้วน ๆ"]
    APP --> PORT
  end
  subgraph INFRA["Infrastructure — adapter สลับได้หลัง seam"]
    direction LR
    EF["EfOrderRepository<br/>EF Core + change tracking<br/>ฝั่งเขียน aggregate ทั้งก้อน"]
    DAP["Dapper (ฝั่งอ่าน hot path)<br/>raw SQL → DTO<br/>ไม่มี change tracking"]
    MAR["MartenOrderRepository<br/>เอกสาร JSONB<br/>document store"]
  end
  PORT -.->|"เลือกที่ composition root"| EF
  PORT -.->|"หรือ"| MAR
  PORT -. "เส้นทางอ่านคู่ขนาน" .-> DAP
  EF --> PG[("PostgreSQL + Npgsql")]
  DAP --> PG
  MAR --> PG

คำ​บรรยาย​ภาพ: ฝั่ง​บน​คือ domain กับ application ที่​เห็น​แค่ port IOrderRepository ซึ่ง​พูด​ภาษา domain ล้วน (รับ/คืน Order, OrderId) ไม่มี SQL หรือ JSONB โผล่​ขึ้น​มา​ถึง​ชั้น​นี้ ฝั่ง​ล่าง​คือ adapter ใน Infrastructure ที่​ทำ​สัญญา​เดียวกัน​ได้​หลาย​ตัว — EF Core สำหรับ​ฝั่ง​เขียน aggregate ทั้ง​ก้อน, Marten สำหรับ​เก็บ​เป็น​เอกสาร JSONB, และ Dapper บน​เส้นทาง​อ่าน​คู่​ขนาน​ที่​คืน DTO ตรงๆ การ​เลือก​ว่า​ใช้​ตัว​ไหน​เกิด​ที่ composition root จุด​เดียว (ลูกศร​ประ) ทั้ง​สาม​ยิง​ไป PostgreSQL ตัว​เดียวกัน​ผ่าน Npgsql — domain ไม่​เคย​รู้​ว่า​ข้าง​หลัง seam มี​อะไร

seam ที่​สลับ adapter ได้​มา​พร้อม​ความ​รับผิดชอบ​หนึ่ง: ทุก adapter ต้อง​พิสูจน์​ว่า​มัน​ทำ​สัญญา​ของ port ได้​จริง กับ​ฐาน​ข้อมูล​จริง เพราะ bug ที่​ร้าย​ที่สุด​ของ​ชั้น persistence — mapping ที่​ตั้ง​ผิด, converter ที่​แปลง​ค่า​กลับ​ไม่​ตรง, SQL ที่ Dapper เขียน​เอง​แล้ว join พลาด, migration ที่​ยัง​ไม่ apply — ไม่มี​ตัว​ไหน​โผล่​ใน test ที่​รัน​บน fake ใน​หน่วย​ความ​จำเลย มัน​โผล่​ต่อ​เมื่อ SQL จริง​วิ่ง​ชน schema จริง​เท่านั้น

Course C บท​ที่ 6 พิสูจน์​วิธี​นี้​ไว้​แล้ว: test EfOrderRepository กับ Postgres ตัว​จริง​ที่ Testcontainers สั่ง​รัน​ชั่วคราว​ใน Docker ต่อ​หนึ่ง​รอบ test แล้ว​ทิ้ง — ได้​ฐาน​ข้อมูล​สะอาด​จริง version ตรง​กับ production จริง โดย​ไม่​ต้อง​มี​ใคร​ติดตั้ง Postgres ไว้​บน​เครื่อง แนวคิด​สำคัญ​คือ test เขียนผ่าน port IOrderRepository ไม่ใช่​ผูก​กับ EF Core:

// FoodOrdering.Integration.Tests/OrderRepositoryTests.cs (ทวนแนวทาง Course C บท 6)
// _repo คือ IOrderRepository — สร้างจาก adapter ตัวไหนก็ได้ที่ผูกกับ Postgres จาก Testcontainers
[Fact]
public async Task Save_then_find_returns_an_equal_aggregate()
{
var id = new OrderId(Guid.NewGuid());
var lineId = new OrderLineId(Guid.NewGuid()); // ประกาศก่อนใช้สร้าง OrderLine
var line = new OrderLine(lineId, new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(120));
var order = Order.Place(id, new[] { line });
await _repo.SaveAsync(order, CancellationToken.None); // เขียนลง Postgres จริง
var loaded = await _repo.FindAsync(id, CancellationToken.None); // อ่านกลับจาก Postgres จริง
Assert.NotNull(loaded);
Assert.Equal(order.Total, loaded!.Total); // Money round-trip ตรงไหม
Assert.Single(loaded.Lines); // OrderLine กลับมาครบไหม
}

เพราะ test ผูก​กับ port ไม่ใช่ adapter ตัว​ใด​ตัว​หนึ่ง ชุด test เดียวกัน​นี้​จึง​กลาย​เป็น สัญญา​ที่​ทุก adapter ต้อง​ผ่าน — วัน​ที่​เรา​เพิ่ม MartenOrderRepository เข้า​มา เรา​ชี้ test ชุด​เดิม​ไป​ที่​มัน​แล้ว​รัน​กับ Postgres จาก Testcontainers ได้​ทันที ถ้า​ผ่าน​หมด แปล​ว่า adapter ใหม่​ทำ​สัญญา​ของ port ได้​เทียบ​เท่าตัว​เดิม​จริง นี่​คือ​สิ่ง​ที่​ทำให้การ​สลับ ORM ปลอดภัย ไม่ใช่​แค่​การ​มี interface สวยๆ

บท​ปิด​ของ​คอร์ส: EF Core รับใช้ Aggregate ไม่ใช่​ทาง​กลับ​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “บท​ปิด​ของ​คอร์ส: EF Core รับใช้ Aggregate ไม่ใช่​ทาง​กลับ​กัน”

ร้อย​ทุก​บท​เข้า​ด้วย​กัน​ด้วย​ประโยค​เดียว: EF Core รับใช้ Order ไม่ใช่​ให้ Order รับใช้ EF Core ทั้ง​แปดบท​คือ​การ​ยืนยัน​ลำดับ​นี้​ซ้ำๆ — บท​ที่ 1 วาง​กฎ​ว่า domain ต้อง​ไม่รู้จัก EF Core, บท 2–4 ใช้ Value Converter/Owned Type/Migration ปิด​ช่องว่าง​โดย​ไม่​แตะ domain, บท 5 คุม optimistic concurrency, บท 6 ทำให้ DbContext เป็น unit of work และ repository บาง​เฉียบ, บท 7 แยก​ฝั่ง​อ่าน​ออก​ด้วย Projection และ​บท​นี้​ปิด​วง​ด้วย​การ​แสดง​ว่า​เมื่อ​ลำดับ​ถูกวาง​ไว้​ถูก การ​ถอด EF Core ออก​ไป​ใส่ Dapper หรือ Marten ก็​เป็น​แค่​การ​เปลี่ยน adapter หลัง seam ไม่ใช่​การ​ผ่าตัด​ใหญ่

ถ้า​วัน​หนึ่ง​ต้อง​เลือก​จริง เกณฑ์​ไม่ใช่ “ตัว​ไหน​เท่​กว่า” แต่​คือ​รูปร่าง​ของ​งาน: ฝั่ง​เขียน​ที่​ต้อง​รักษา invariant ของ aggregate — EF Core map ทั้ง​ก้อน​ให้​ซื่อสัตย์; เส้นทาง​อ่าน​ที่​ร้อน​จัด​และ SQL ซับซ้อน — หล่น​ไป Dapper หลัง seam; aggregate ที่​เป็น​เอกสาร​โดย​ธรรมชาติ​หรือ​อยาก​ได้ event store — Marten และ​ไม่​ว่า​เลือก​ตัว​ไหน สิ่ง​ที่​ห้าม​ยอม​คือ​ให้​เครื่องมือ​ลาก model กลับ​ไป​กลวง domain ที่​รักษา​กฎ​ของ​ตัวเอง​ได้​เท่า​กัน​ทั้ง​ใน​หน่วย​ความ​จำ​และ​ใน PostgreSQL คือ​คำ​สัญญา​ที่​คอร์ส​นี้​ให้​ไว้​ตั้งแต่​บท​แรก และ​เป็น​สิ่ง​เดียว​ที่​ต้อง​ไม่​เปลี่ยน ไม่​ว่า​ข้าง​หลัง seam จะ​เป็น ORM ตัว​ไหน​ก็ตาม


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

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

  • Repository — seam ที่​ทำให้ domain เข้าถึง aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ และ​ซ่อน​ว่า​ข้าง​หลัง​เป็น EF Core, Dapper หรือ Marten
  • Adapter — pattern ที่ EfOrderRepository/MartenOrderRepository เป็นอยู่: ทำ​สัญญา​ของ port เดียวกัน​ด้วย​กลไก​ที่​ต่าง​กัน สลับ​กัน​ได้ที่ composition root
  • Golden Hammer — anti-pattern ที่​ใช้​เครื่องมือ​โปรด​ตี​ทุก​งาน​โดย​ไม่​ถาม​ว่า​มัน​เข้า​กับ​ปัญหา​ไหม บท​นี้​คือ​ยา​แก้​ของ​มัน​ใน​บริบท persistence

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

ข้อ 1 / 3

เมื่อไรที่ควรหล่นไป Dapper/raw SQL แทน EF Core?