Interface, generics และ DI — port และการพึ่งพา abstraction
หกบทที่ผ่านมาเราปั้นก้อนอิฐของ domain Order จนแน่น — Money, OrderId, Quantity, OrderLine และตัว Order เองที่รักษากฎธุรกิจของมัน ทั้งหมดนี้ยังเป็นโลก “ในหัว” ล้วนๆ คือคำนวณและตัดสินใจอยู่ในหน่วยความจำ ไม่ได้แตะโลกภายนอกเลย
แต่ app จริงต้องแตะโลกภายนอก: ตัดเงินผ่านผู้ให้บริการชำระเงิน (เช่น Stripe) และบันทึกออเดอร์ลงฐานข้อมูล (เช่น SQL) คำถามใหญ่ของบทนี้คือ — จะให้ domain สั่งงานพวกนี้ได้ โดยที่ domain ไม่ต้องรู้จัก Stripe หรือ SQL เลยได้ยังไง? คำตอบคือเครื่องมือสามชิ้นของ C# ที่ทำงานร่วมกัน: interface, generics และ dependency injection
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/ และการเดินสายที่ Program.cs ตรงขอบ
interface คือสัญญา
หัวข้อที่มีชื่อว่า “interface คือสัญญา”InterfaceInterfaceสัญญา (contract) ที่ประกาศว่า 'ต้องมี method/พฤติกรรมอะไรบ้าง' โดยไม่ผูกกับวิธี implement เช่น `IOrderRepository` — ทำให้ domain พึ่งพาแค่ abstraction ส่วน implementation จริง (เช่นต่อฐานข้อมูล) ใส่เข้ามาทีหลังผ่าน Dependency InjectionArchitecture คือ “สัญญา” (contract) ที่ประกาศว่า type ที่ทำตามสัญญานี้ ต้องมี method อะไรบ้าง แต่ไม่บอกว่า method นั้นทำงานยังไงข้างใน ลองดูสัญญาสำหรับ “ตัวตัดเงิน”:
namespace FoodOrdering.Domain.Orders;
public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}แกะทีละส่วน:
interfaceคือ keyword ที่บอก compiler ว่า “นี่คือสัญญา ไม่ใช่ของจริง”- ชื่อขึ้นต้นด้วยตัว
I(เช่นIPaymentGateway) เป็นธรรมเนียมของ C# ที่ทำให้เห็นปุ๊บว่านี่คือ interface - ข้างในมีแต่ หัว method — ชื่อ, สิ่งที่รับเข้า, สิ่งที่คืนออก — แต่ ไม่มีเนื้อใน (ไม่มี
{ ... }ตามหลัง) เพราะ interface ไม่สนใจว่าจะตัดเงินยังไง สนแค่ว่า “ต้องตัดเงินได้” กับ “ต้องคืนเงินได้” Taskที่เห็นแปลว่า “งานที่ใช้เวลา” เพราะการคุยกับธนาคารไม่ได้เสร็จทันที ส่วนCancellationTokenคือ “ปุ่มยกเลิก” — ยังไม่ต้องเข้าใจลึกตอนนี้ บทที่ 8 จะเจาะ async/await เต็มๆ ตรงนี้ ตอนนี้แค่รู้ว่าทั้งสองตัวเป็นส่วนหนึ่งของหัวสัญญา
interface แบบนี้เรียกในภาษา DDD ว่า port (port) — เป็นช่องเสียบที่ domain ใช้คุยกับโลกภายนอก domain ถือแค่ “สัญญา” IPaymentGateway ไว้ ไม่เคยรู้เลยว่าปลายทางจริงคือ Stripe, PayPal หรือตัวปลอมตอน test นี่คือหัวใจ: domain พึ่งพา abstraction ไม่ใช่ของจริง
การเก็บและดึงออเดอร์จากฐานข้อมูลก็เป็น port แบบเดียวกัน — สัญญาว่า “ต้องเก็บและดึงออเดอร์ได้” โดยไม่บอกว่าเก็บใน SQL, MongoDB หรือ file แต่ port ตัวนั้นเขียนสวยที่สุดด้วยเครื่องมือชิ้นถัดไป เราจึงเก็บ IOrderRepository ไว้ปั้นในหัวข้อ generics เลย
version ดิบ: new SqlOrderRepository() ใน domain
หัวข้อที่มีชื่อว่า “version ดิบ: new SqlOrderRepository() ใน domain”ก่อนจะเห็นว่า port ช่วยอะไร ลองดูสิ่งที่เกิดขึ้นถ้า ไม่ ใช้มัน นี่คือ ❌ version ดิบ ที่ domain สร้างของจริงขึ้นมาเองข้างใน:
// ❌ version ดิบ: domain ผูกตายกับ infrastructure จริงpublic class CheckoutHandler{ public void Checkout(OrderId id) { var orders = new SqlOrderRepository("Server=prod;Database=orders;..."); // ผูกตายกับ SQL var payments = new StripePaymentGateway("sk_live_51H..."); // ผูกตายกับ Stripe
// ...ตรรกะ checkout... }}ตรง new SqlOrderRepository(...) และ new StripePaymentGateway(...) ดูไม่มีพิษภัย แต่มันทำให้ domain พังในสามทาง:
- ผูกตายกับของจริง — code นี้เรียกใช้ SQL และ Stripe ตรงๆ จะสลับไปใช้ที่อื่น (หรือใช้ตัวปลอมตอน test) ไม่ได้เลย นอกจากไปแก้ code domain ทิ้ง
- test แทบไม่ได้ — ทุกครั้งที่รัน test
Checkoutจะต่อฐานข้อมูลจริงและ รูดบัตรจริง เพราะมันสร้างของจริงขึ้นมาเองเสมอ - dependency ซ่อนอยู่ — คนอ่านหัว class
CheckoutHandlerมองไม่ออกเลยว่ามันต้องพึ่ง repository กับ payment gateway จนกว่าจะไล่อ่านข้างใน method แถม connection string กับ secret key ยังหลุดเข้ามาปนอยู่ใน domain อีก
รากของปัญหาคือ “domain สร้าง dependency ของตัวเอง” ทางแก้คือกลับด้านมัน — ให้ dependency ถูกส่งเข้ามาจากภายนอก ผ่าน port ที่เหลือของบทนี้คือการประกอบเครื่องมือที่ทำให้เรื่องนี้เกิดขึ้นอย่างเป็นระบบ
generics — เขียนครั้งเดียว ใช้ได้หลายชนิด
หัวข้อที่มีชื่อว่า “generics — เขียนครั้งเดียว ใช้ได้หลายชนิด”ก่อนไปต่อเรื่อง DI เราต้องรู้จักเครื่องมืออีกชิ้นที่โผล่มาใน code domain บ่อยมาก GenericsGenericsความสามารถเขียน type หรือ method ที่ทำงานกับ type ใดก็ได้โดยไม่ต้องรู้ล่วงหน้า ระบุด้วย type parameter เช่น `List<T>`, `IReadOnlyList<OrderLine>` — ทำให้เขียน code (เช่น Repository หรือ collection ห่อหุ้ม) ที่ type-safe และใช้ซ้ำได้กับหลายชนิดข้อมูลArchitecture คือความสามารถเขียน type หรือ method ที่ทำงานกับ ชนิดข้อมูลใดก็ได้ โดยไม่ต้องรู้ล่วงหน้าว่าเป็นชนิดอะไร เราระบุช่องว่างนั้นด้วย type parameter — ตัวอักษรในวงเล็บมุม <...> (นิยมใช้ T ย่อจาก Type)
ที่จริงเราใช้ generics มาตั้งแต่บทก่อนแล้วโดยไม่รู้ตัว — List<OrderLine> คือ “list ที่บรรจุ OrderLine” และ IReadOnlyCollection<OrderLine> คือ “collection อ่านอย่างเดียวของ OrderLine” ตัว List<T> เขียนครั้งเดียวใน .NET แล้วใช้ได้กับทุกชนิด — List<int>, List<Money>, List<Order> — เพราะ T เป็นช่องว่างที่เราเติมชนิดเข้าไปทีหลัง
ทีนี้มาเขียน generic ของเราเองบ้าง entity (entity) ทุกตัวใน domain มีสิ่งหนึ่งเหมือนกัน: มันมี identity คือ “เลขประจำตัว” ที่ทำให้มันเป็น “ตัวไหน” ต่างจาก Money ที่เป็นแค่ “ค่า” เราจึงทำ base class กลางชื่อ Entity<TId> ที่เก็บ Id และเทียบความเท่ากันด้วย Id ให้ทุก entity ใช้ร่วมกัน:
namespace FoodOrdering.Domain;
public abstract class Entity<TId> where TId : notnull{ public TId Id { get; }
protected Entity(TId id) => Id = id;
// entity สองตัวเท่ากันเมื่อ "Id เท่ากัน" ไม่ใช่ตอนที่ทุก field เท่ากัน public override bool Equals(object? obj) => obj is Entity<TId> other && Id.Equals(other.Id);
public override int GetHashCode() => Id.GetHashCode();}มีของใหม่สองอย่าง:
Entity<TId>—TIdคือ type parameter สำหรับ “ชนิดของ Id” entity คนละตัวอาจใช้ Id คนละชนิด (OrderLineใช้OrderLineId,Orderใช้OrderId) base class เดียวนี้จึงรองรับได้หมดโดยไม่ต้องเขียนซ้ำ ส่วนabstractแปลว่า “สร้างตรงๆ ไม่ได้ ต้องมีคนสืบทอดไปใช้”where TId : notnull— ท่อนนี้คือ generic constraint (เงื่อนไขของ generic) มันจำกัดว่า “ชนิดที่จะเอามาเติมช่องTIdต้องเป็นชนิดที่เป็นnullไม่ได้” ประโยชน์คือ พอ compiler การันตีว่าIdไม่มีทางเป็นnullcode ข้างในอย่างId.Equals(other.Id)ก็เรียกได้อย่างมั่นใจ ไม่ระเบิดกลางทาง — constraint คือการซื้อความมั่นใจให้ code ข้างใน generic
ตอนนี้ OrderLine แค่สืบทอด Entity<OrderLineId> ก็ได้ identity กับการเทียบความเท่ากันมาฟรีๆ สังเกตว่า OrderLine เป็น class ไม่ใช่ record — เพราะมันมีตัวตน (identity) ต่างจาก Money/OrderId ที่เป็น record เพราะเป็นแค่ค่า:
namespace FoodOrdering.Domain.Orders;
public sealed class OrderLine : Entity<OrderLineId>{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) // ส่ง id ต่อขึ้นไปให้ base class Entity<OrderLineId> เก็บ { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }}ผลของการเทียบด้วย identity เห็นชัดตอนใช้จริง — สองบรรทัดที่ Id เดียวกันถือว่าเป็น “ออเดอร์ไลน์เดียวกัน” แม้ของข้างในต่างกัน:
var id = new OrderLineId(Guid.NewGuid());var a = new OrderLine(id, padThai, new Quantity(1), Money.Thb(50));var b = new OrderLine(id, greenCurry, new Quantity(2), Money.Thb(90));
bool same = a.Equals(b); // true! เพราะ Id เดียวกัน — entity เทียบด้วย identity ไม่ใช่ค่าrepository ก็ใช้ generics ได้เหมือนกัน — port ส่วนใหญ่หน้าตาคล้ายกัน (หาด้วย id, เพิ่มของใหม่) เราจึงยกส่วนที่ซ้ำขึ้นไปไว้ใน IRepository<T> กลาง:
namespace FoodOrdering.Domain;
public interface IRepository<T> where T : class{ Task<T?> FindAsync(Guid id, CancellationToken ct); Task AddAsync(T entity, CancellationToken ct);}where T : class บอกว่า T ต้องเป็น reference type (class) — จึงมีความหมายที่จะคืน T? (“เจอ หรือ ไม่เจอเป็น null”) ได้ พอมี base สัญญานี้แล้ว IOrderRepository ก็แค่ต่อยอดด้วย method เฉพาะของออเดอร์:
namespace FoodOrdering.Domain.Orders;
public interface IOrderRepository : IRepository<Order>{ // method เฉพาะที่ repository ทั่วไปไม่มี Task<IReadOnlyList<Order>> FindByStatusAsync(OrderStatus status, CancellationToken ct);}primary constructor — รับ dependency ให้สั้นลง
หัวข้อที่มีชื่อว่า “primary constructor — รับ dependency ให้สั้นลง”พอ domain พึ่ง port แทนของจริง ทุกๆ ตัวที่ต้องใช้ port ก็ต้อง รับมันเข้ามาทาง constructor วิธีดั้งเดิมคือประกาศ field แล้วเขียน constructor คอยรับค่ามาใส่ field — code ซ้ำๆ ที่เรียกว่า boilerplate:
// วิธีดั้งเดิม: ประกาศ field + เขียน constructor รับค่าเองpublic class CheckoutHandler{ private readonly IOrderRepository _orders; private readonly IPaymentGateway _payments;
public CheckoutHandler(IOrderRepository orders, IPaymentGateway payments) { _orders = orders; _payments = payments; }
// ...method ที่ใช้ _orders กับ _payments...}Primary ConstructorPrimary Constructorไวยากรณ์ย่อ (C# 12) ที่ประกาศ parameter ของ constructor ไว้บนหัว class/struct ได้เลย เช่น `class DeliveryFeeCalculator(IDistanceService distance)` — ลด code boilerplate ของการเขียน field + constructor แยกกันเวลาที่ต้องการแค่เก็บ dependency หรือค่าที่รับเข้ามาTactical Design (มาใน C# 12) ย่อทั้งหมดนั้นให้เหลือการประกาศ parameter ไว้บนหัว class เลย parameter ที่ประกาศตรงนั้นเรียกใช้ได้ทั่วทั้ง class โดยไม่ต้องทำ field เอง:
namespace FoodOrdering.Application.Orders;
// parameter บนหัว class = primary constructorpublic class CheckoutHandler(IOrderRepository orders, IPaymentGateway payments){ public async Task CheckoutAsync(OrderId id, CancellationToken ct) { var order = await orders.FindAsync(id.Value, ct) ?? throw new InvalidOperationException("ไม่พบออเดอร์นี้");
await payments.ChargeAsync(order.Total, ct); }}code 2 version ทำงานเหมือนกันเป๊ะ แต่ version primary constructor สั้นกว่ามาก และหัว class (IOrderRepository orders, IPaymentGateway payments) ประกาศชัดๆ ตั้งแต่บรรทัดแรกว่า “ตัวนี้ต้องพึ่ง2 port นี้” — ตรงข้ามกับ version ดิบก่อนหน้าที่ซ่อน dependency ไว้ใน method (สังเกตว่า CheckoutHandler นี่อยู่ใน FoodOrdering.Application คือชั้น “app” ที่ประสานงาน ส่วน domain แท้ๆ ยังบริสุทธิ์อยู่ใน FoodOrdering.Domain)
dependency injection — ต่อสายที่ขอบ
หัวข้อที่มีชื่อว่า “dependency injection — ต่อสายที่ขอบ”เหลือคำถามสุดท้าย: ถ้า CheckoutHandler แค่ “รับ” IOrderRepository กับ IPaymentGateway เข้ามา แล้ว ใครเป็นคนหาของจริงมาเสียบให้? คำตอบคือ Dependency InjectionDependency Injectionเทคนิคที่อ็อบเจ็กต์ 'รับ' dependency ของมันเข้ามาจากภายนอก (ผ่าน constructor เป็นหลัก) แทนที่จะสร้างเองข้างใน — ทำให้สลับ implementation ได้ (เช่น mock ตอน test) และ code พึ่งพาแค่ interface ไม่ผูกกับรายละเอียด infrastructureArchitecture (DI) — เทคนิคที่อ็อบเจ็กต์รับ dependency ของมันเข้ามาจากภายนอก แทนที่จะสร้างเอง การส่งผ่าน constructor แบบที่เราเพิ่งทำเรียกว่า constructor injection
แล้วของจริงมาจากไหน? มันถูกผูกไว้ที่ container — จุดเดียวที่ “ขอบ” ของระบบ (ใน .NET คือ file Program.cs) ที่รู้จักของจริงทั้งหมด และคอยบอกว่า “พอมีใครขอ interface นี้ ให้หยิบ implementation ตัวนี้ไปให้”:
// Program.cs — "ขอบ" ของระบบ จุดเดียวที่รู้จักของจริงvar builder = WebApplication.CreateBuilder(args);
// เดินสาย: ผูก interface (port) เข้ากับ implementation จริง (adapter)builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();builder.Services.AddScoped<CheckoutHandler>();
var app = builder.Build();บรรทัด AddScoped<IOrderRepository, SqlOrderRepository>() อ่านว่า “เวลามีใครขอ IOrderRepository ให้สร้าง SqlOrderRepository ไปให้” พอ container สร้าง CheckoutHandler มันเห็นว่า constructor ขอ2 port ก็หยิบของจริงที่ผูกไว้มาเสียบให้อัตโนมัติ — โดยที่ CheckoutHandler ไม่เคยเอ่ยชื่อ SqlOrderRepository หรือ StripePaymentGateway เลยสักครั้ง
ผลลัพธ์ทั้งหมดสรุปเป็นภาพนี้: domain อยู่ตรงกลางพึ่งแค่ port, adapter ของจริงอยู่นอกสุด, และมี container ที่ขอบเป็นคนเดินสายเชื่อมสองฝั่ง
flowchart TB
subgraph DOMAIN["code ของเรา (app + domain) — พึ่งแค่ port"]
APP["CheckoutHandler"]
PORT1["IPaymentGateway (port)"]
PORT2["IOrderRepository (port)"]
APP --> PORT1
APP --> PORT2
end
subgraph INFRA["Infrastructure ที่ขอบ — ของจริง"]
ADP1["StripePaymentGateway"]
ADP2["SqlOrderRepository"]
end
ADP1 -. ทำตามสัญญา .-> PORT1
ADP2 -. ทำตามสัญญา .-> PORT2
DI["DI Container ที่ Program.cs<br/>ต่อสายที่ขอบ"]
DI -->|ผูก IPaymentGateway กับ StripePaymentGateway| ADP1
DI -->|ผูก IOrderRepository กับ SqlOrderRepository| ADP2
คำบรรยายภาพ: ลูกศรจาก CheckoutHandler ชี้ไปที่ port (interface) เท่านั้น — code ฝั่งเราไม่เคยชี้ไปที่ StripePaymentGateway หรือ SqlOrderRepository ตรงๆ เลย ส่วน adapter ของจริงเป็นฝ่าย “ทำตามสัญญา” (implements) ชี้กลับเข้าหา port ทิศของการพึ่งพาจึงชี้เข้าหา abstraction เสมอ และมี DI container ที่ขอบ เป็นที่เดียวที่รู้จักของจริงและคอยเดินสายผูก port เข้ากับ adapter ตอน app เริ่มทำงาน นี่คือรูปทรงเดียวกับที่ version ดิบทำไม่ได้ เพราะมันสร้างของจริงปนอยู่ใน domain
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้ทำให้ domain Order พึ่งพาแค่ abstraction: interface เป็น port, generics ทำ base class และ repository ที่ใช้ซ้ำได้, primary constructor รับ dependency สั้นๆ และ DI เดินสายของจริงเข้ามาที่ขอบ domain จึง test ได้และสลับ infrastructure ได้โดยไม่ต้องแก้ตัวมันเอง
เหลืออีกปมหนึ่งที่เราเลื่อนไว้ตลอดบท: Task, async, await และ CancellationToken ที่โผล่มาในทุก port
- บทที่ 8 — async/await และ
Task: ทำไม domain แท้ๆ ยังคง synchronous (ตัดสินใจล้วนๆ ไม่รออะไร) ส่วน async ไปอยู่ที่ ขอบ ที่ต้องคุยกับ I/O จริง และปิดคอร์สด้วยแผนที่ว่าแต่ละ feature ที่เรียนมาไปต่อยอดที่คอร์สไหนของสาย .NET/DDD
ส่วนการออกแบบ port กับ adapter ให้ลึกในเชิงสถาปัตยกรรม (ports and adapters, dependency inversion เต็มรูป) เก็บไว้ที่คอร์ส Clean Architecture .NET และเรื่อง repository กับ aggregate ในเชิง DDD ที่คอร์ส DDD in Code — บทนี้โฟกัสที่ “ไวยากรณ์ C# ที่ทำให้ port เกิดขึ้นได้” ก่อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Dependency Inversion Principle (DIP) — หลักการเบื้องหลังทั้งบท: module ระดับสูง (domain) ไม่ควรพึ่ง module ระดับล่าง (infrastructure) แต่ทั้งคู่ควรพึ่ง abstraction เดียวกัน
- Dependency Injection — แนวปฏิบัติของการส่ง dependency เข้ามาจากภายนอกและเดินสายที่ container ตรงขอบ
- Repository Pattern — pattern เบื้องหลัง
IOrderRepository: ห่อการเก็บ/ดึงข้อมูลไว้หลัง interface ให้ domain ไม่รู้จักฐานข้อมูลจริง
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3interface (port) อย่าง `IPaymentGateway` ให้อะไรกับ domain?