เมื่อ 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 เต็มของคอร์สนี้อยู่ที่ 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 ของ OrderLinepublic 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 เก่งฝั่งเขียน แต่เริ่มฝืดตรง hot read path
หัวข้อที่มีชื่อว่า “EF Core เก่งฝั่งเขียน แต่เริ่มฝืดตรง hot read path”ทบทวนสิ่งที่ 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 ไปโปรยไว้กลางตรรกะธุรกิจ นี่คือวิธีที่ผิด —
// อยู่กลาง 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 สำหรับเส้นทางอ่านที่ร้อนจัด
หัวข้อที่มีชื่อว่า “หล่นไป Dapper สำหรับเส้นทางอ่านที่ร้อนจัด”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 เลย:
using Dapper;using Npgsql;
// DTO สำหรับหน้ารายการ — ไม่ใช่ entity ไม่มี identity ไม่มี behaviorpublic 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 เหมือนกัน ต่างแค่ชั้นที่คั่นอยู่
Marten กับทางเลือกแบบ document store
หัวข้อที่มีชื่อว่า “Marten กับทางเลือกแบบ document store”บางครั้งคำถามไม่ใช่ “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:
using Marten;
// adapter ตัวเดียวกับ port IOrderRepository เป๊ะ — แต่เก็บ Order เป็นเอกสาร JSONBpublic 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 เส้นเดียวที่เราจะพูดถึงต่อไป
IOrderRepository คือ seam ที่ทำให้สลับได้
หัวข้อที่มีชื่อว่า “IOrderRepository คือ 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 ให้อยู่แล้ว:
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 มีอะไร
ทดสอบ persistence กับฐานข้อมูลจริงด้วย Testcontainers
หัวข้อที่มีชื่อว่า “ทดสอบ persistence กับฐานข้อมูลจริงด้วย Testcontainers”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:
- 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?