Composition Root — ต่อสาย DI ให้ dependency ชี้เข้าใน
Program.cs โผล่มาให้เห็นแล้วสองครั้งในคอร์สนี้ — บทที่ 4 เพิ่ม builder.Services.AddScoped<IOrderRepository, EfOrderRepository>() เข้าไปหนึ่งบรรทัด บทที่ 5 เพิ่ม AddMediatR, AddValidatorsFromAssemblyContaining, AddExceptionHandler เข้าไปอีกสี่บรรทัด แต่ไม่มีบทไหนเคยหยุดมอง file นี้ทั้ง file พร้อมกันสักที ก่อนจะไปเขียน test พิสูจน์ในบทถัดไปว่าทุกอย่างที่พูดมาตลอดห้าบทเป็นจริง บทนี้ขอพาไปยืนมองจุดเดียวที่ port ทุกตัวจาก Domain กับ Application — IOrderRepository, IMenuCatalog, IPaymentGateway — มาเจอกับ adapter จริงของมันพร้อมกันทั้งหมดเป็นครั้งแรก
code เต็มของบทนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — path: FoodOrdering.Web/Program.cs
จุดเดียวที่รู้จักทุกอย่าง
หัวข้อที่มีชื่อว่า “จุดเดียวที่รู้จักทุกอย่าง”ลองไล่ดูสิ่งที่เรารู้อยู่แล้วจากห้าบทที่ผ่านมา: FoodOrdering.Domain ไม่รู้จัก FoodOrdering.Infrastructure เลยแม้แต่ชื่อเดียว, FoodOrdering.Application ประกาศ IOrderRepository/IMenuCatalog/IPaymentGateway ไว้เป็น interface โดยไม่รู้ว่าใครจะมา implement, และ FoodOrdering.Infrastructure ก็ implement EfOrderRepository โดยไม่รู้ว่าใครจะมาเรียกใช้มัน — ทุก project ต่างรู้จัก “ครึ่งเดียว” ของความสัมพันธ์เสมอ คำถามคือแล้วใครประกอบทั้งสองครึ่งเข้าด้วยกันตอน app รันจริง
คำตอบคือ composition rootComposition Rootจุดเดียวใกล้ขอบสุดของ app (ใน .NET คือ Program.cs / DI container) ที่ประกอบ interface เข้ากับ implementation จริง เป็นที่เดียวที่ 'รู้จักทุกอย่าง'Architecture — จุดเดียวใกล้ขอบสุดของ app ที่ได้รับอนุญาตให้ “รู้จักทุกอย่าง” พร้อมกัน ทั้ง interface ที่วงในประกาศไว้และ implementation จริงที่วงนอกเตรียมไว้ ใน .NET จุดนั้นคือ Program.cs ของ FoodOrdering.Web เพราะมันคือวงนอกสุดที่ ProjectReference ชี้เข้าหาทั้ง Application และ Infrastructure (ตามที่บทที่ 1 วางไว้) และ DI container ที่ WebApplicationBuilder สร้างให้ก็คือกลไกที่ทำหน้าที่ “ประกอบ” นั้นจริงๆ ข้อสำคัญคือ composition root ไม่ใช่แค่ที่ที่มี code DI เยอะที่สุด แต่มันควรเป็นที่เดียวในทั้งโซลูชันที่ทำแบบนี้ — ถ้าเริ่มมีการ new EfOrderRepository(...) ตรงๆ กระจายอยู่หลายที่ นั่นคือสัญญาณว่า composition root กำลังรั่วออกไปนอกขอบของมัน
flowchart LR
IOR["IOrderRepository «interface»"] --> DI{{"DI Container<br/>Program.cs"}}
IMC["IMenuCatalog «interface»"] --> DI
IPG["IPaymentGateway «interface»"] --> DI
ICL["IClock «interface»"] --> DI
DI --> EOR["EfOrderRepository"]
DI --> SMC["StaticMenuCatalog"]
DI --> MPG["MockPaymentGateway"]
DI --> SCL["SystemClock"]
classDef inner fill:#16a34a,stroke:#065f46,color:#f8fafc;
class IOR,IMC,IPG,ICL inner;
คำบรรยายภาพ: interface ทั้งสี่ตัวทางซ้าย (วงใน — เขียวเข้ม) ไม่รู้จัก implementation ทางขวาเลย และ implementation ทางขวาก็ไม่รู้จักกันเองด้วยซ้ำ — มีแค่ DI container ตรงกลางที่ Program.cs เท่านั้นที่เห็นทั้งสองฝั่งพร้อมกัน แล้วผูกเส้นให้ตอน app สตาร์ต
ผูก port เข้ากับ adapter ด้วย Dependency Injection
หัวข้อที่มีชื่อว่า “ผูก port เข้ากับ adapter ด้วย Dependency Injection”กลไกที่ DI container ใช้ผูกสองฝั่งเข้าด้วยกันเรียกว่า dependency injectionDependency Injectionเทคนิคส่ง dependency (ที่ implement ตาม interface) เข้ามาจากภายนอกแทนที่จะสร้างเอง ทำให้วงในพึ่งพา abstraction ไม่ใช่ของจริง และสลับ/ทดสอบได้ง่ายArchitecture — แทนที่ PlaceOrderHandler จะ new EfOrderRepository() ขึ้นมาเอง (ซึ่งจะทำให้ Application ต้อง reference EF Core ทันที ผิด Dependency Rule ตั้งแต่บทที่ 1) เราส่ง implementation ที่พร้อมใช้งานแล้วเข้าไปให้จากภายนอกผ่านคอนสตรักเตอร์แทน — ตัว handler เองไม่ต้องรู้ด้วยซ้ำว่ามันได้ EfOrderRepository ตัวจริงมา หรือ fake ตัวหนึ่งสำหรับ test ทุกการลงทะเบียนแบบนี้ก็คือการบอก container ว่า “เวลามีใครขอ IOrderRepository ให้เอา EfOrderRepository มาให้”
// FoodOrdering.Infrastructure/Menu/StaticMenuCatalog.cs — adapter แบบง่าย รอต่อเมนูจริงจาก repo ตัวอย่างpublic sealed class StaticMenuCatalog : IMenuCatalog{ public Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct) => Task.FromResult(Money.Thb(100)); // ราคาคงที่ชั่วคราว — ของจริงยิง HTTP ไปหาระบบเมนูร้านค้า}
// FoodOrdering.Infrastructure/Payments/MockPaymentGateway.cs — adapter แบบง่าย รอต่อ payment gateway จริงpublic sealed class MockPaymentGateway : IPaymentGateway{ public Task ChargeAsync(Money amount, CancellationToken ct) => Task.CompletedTask; // ของจริงเรียก SDK ของผู้ให้บริการชำระเงินจริง}ทั้ง2 class ข้างบนเป็น adapterAdaptercode วงนอกที่ implement port เพื่อเชื่อมกับเทคโนโลยีจริง เช่น EF Core repository หรือ HTTP payment clientArchitecture ตามนิยามเดียวกับ EfOrderRepository ในบทที่ 4 — code วงนอกที่ implement portPortinterface ที่วงในประกาศไว้เพื่อคุยกับโลกภายนอก (เช่น IPaymentGateway) โดยไม่รู้จัก implementation จริงArchitecture เพื่อคุยกับเทคโนโลยีจริง เพียงแต่ตอนนี้ยังเป็น version ตัวอย่าง รอวันที่ต่อ HTTP client เข้าหาระบบเมนูร้านค้าและผู้ให้บริการชำระเงินจริงใน code ตัวอย่างเต็มของคอร์ส ประเด็นของบทนี้ไม่ใช่รายละเอียดข้างในของ adapter พวกนี้ แต่คือที่ที่มันถูกประกอบเข้ากับ interface — ซึ่งเกิดขึ้นที่เดียวกันหมดใน Program.cs:
// FoodOrdering.Web/Program.cs — composition root: ทุก port จากบทที่ 1–5 มาบรรจบกันที่นี่var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<FoodOrderingDbContext>(opt => opt.UseNpgsql(builder.Configuration.GetConnectionString("FoodOrdering"))); // บทที่ 4
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>(); // บทที่ 4builder.Services.AddScoped<IMenuCatalog, StaticMenuCatalog>(); // บทที่ 3 ประกาศ port, บทนี้ผูก adapterbuilder.Services.AddScoped<IPaymentGateway, MockPaymentGateway>(); // บทที่ 3 ประกาศ port, บทนี้ผูก adapter
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<PlaceOrderHandler>()); // บทที่ 3/5builder.Services.AddValidatorsFromAssemblyContaining<PlaceOrderCommandValidator>(); // บทที่ 3/5builder.Services.AddExceptionHandler<DomainExceptionHandler>(); // บทที่ 5builder.Services.AddProblemDetails(); // บทที่ 5
var app = builder.Build();app.UseExceptionHandler();
app.MapPost("/orders", /* endpoint เต็มจากบทที่ 5 — ไม่แก้อะไรเลย */ (PlaceOrderRequest r, ISender s, CancellationToken ct) => { });app.MapGet("/orders/{id:guid}", /* endpoint เต็มจากบทที่ 5 — ไม่แก้อะไรเลย */ (Guid id, ISender s, CancellationToken ct) => { });
app.Run();สังเกตว่าทุกบรรทัดในนี้เคยเห็นมาแล้วทั้งนั้น ไม่ว่าจะเป็น type ที่ประกาศไว้ก่อนหน้า (IOrderRepository บทที่ 3, FoodOrderingDbContext บทที่ 4) หรือ endpoint ทั้งสองตัว (บทที่ 5) — บทนี้แค่รวบทุกอย่างมาไว้ใน file เดียวให้เห็นภาพเต็มๆ เป็นครั้งแรก ISender ที่ endpoint ขอมาก็มาจาก MediatRMediatRlibrary .NET ยอดนิยมสำหรับส่ง command/query/notification ไปยัง handler แบบ in-process ช่วยแยก Application layer ออกจาก WebArchitecture ที่ AddMediatR(...) ลงทะเบียนไว้ข้างบน — endpoint ไม่ต้อง new PlaceOrderHandler(...) เองเลยสักที่ เพราะ MediatR ใช้ container ตัวเดียวกันนี้หา handler ที่ถูกต้องให้อัตโนมัติ
Hollywood Principle — “อย่าโทรหาเรา เดี๋ยวเราโทรหาคุณ”
หัวข้อที่มีชื่อว่า “Hollywood Principle — “อย่าโทรหาเรา เดี๋ยวเราโทรหาคุณ””รูปแบบที่ PlaceOrderHandler ไม่ เรียก new EfOrderRepository() เอง แต่รอให้ใครสักคนส่งมันเข้ามาให้แทน มีชื่อเรียกว่า Inversion of Control (IoC) — กลับทิศการควบคุมจาก “code ของเราเป็นฝ่ายเรียก library” มาเป็น “framework/container เป็นฝ่ายเรียก code ของเรา” ตัวอย่างที่จับต้องง่ายที่สุดของ IoC ก็คือ ASP.NET Core เองนั่นคือ — เราไม่ได้เขียน loop มารอรับ HTTP request เอง แต่เขียน endpoint ทิ้งไว้แล้วปล่อยให้ framework เป็นฝ่ายเรียกมันตอนมี request เข้ามาจริง
หลักการเดียวกันนี้มีชื่อเล่นที่จำง่ายกว่าคือ Hollywood Principle — “Don’t call us, we’ll call you” ชวนให้นึกถึงตอนไปคัดตัวนักแสดงแล้วสตูดิโอบอกว่าอย่าโทรตาม เดี๋ยวเราโทรหาเอง PlaceOrderHandler ก็เหมือนนักแสดงที่ “สมัคร” ไว้ผ่าน constructor ว่าต้องการ IOrderRepository, IMenuCatalog, IPaymentGateway แบบไหน แล้วปล่อยให้ composition root เป็นฝ่าย “โทรกลับมา” ตอนจำเป็น — ไม่ใช่ handler ที่วิ่งไปเสาะหาของพวกนี้เอง Dependency Injection ที่เราเขียนใน Program.cs ข้างบนก็คือการทำ IoC ให้เป็นรูปธรรมส่วน Hexagonal ArchitectureHexagonal ArchitecturePorts & Adapters ของ Alistair Cockburn — แกน app อยู่กลาง คุยกับภายนอกผ่าน port/adapter เท่านั้น พูดเรื่องเดียวกับ Clean Architecture ด้วยรูปทรงต่างกันArchitecture ที่บทที่ 1 พูดถึงไว้ก็มองเรื่องเดียวกันนี้ผ่านรูปหกเหลี่ยม — core อยู่กลาง ส่วน port/adapter ล้อมรอบ แล้วมีจุดประกอบ (composition root) ที่ทำให้ทั้งรูปนั้นขยับได้จริง
C2 seam (พรีวิว): กฎ 5 นาทีอยู่วงใน นาฬิกาเป็น adapter วงนอก
หัวข้อที่มีชื่อว่า “C2 seam (พรีวิว): กฎ 5 นาทีอยู่วงใน นาฬิกาเป็น adapter วงนอก”จนถึงตอนนี้ ports ทุกตัวที่เราผูกใน composition root คุยกับ “ของนอกที่ต้องอ่าน/เขียนข้อมูล” — ฐานข้อมูล, เมนูร้านค้า, payment gateway แต่ยังมี dependency ภายนอกอีกแบบหนึ่งที่มักถูกมองข้าม นั่นคือ เวลา ลองนึกภาพนโยบายที่ทีมธุรกิจอยากได้ต่อไป: “ถ้าร้านอาหารไม่กดยืนยันออเดอร์ภายใน 5 นาที ให้ระบบยกเลิกออเดอร์นั้นให้อัตโนมัติ” — นี่คือตัวอย่างของเคสซับซ้อนที่คอร์สนี้เรียกว่า C2 (ปัญหาเรื่อง lifecycle/timeout ของ aggregate) ที่ต่างจาก C1 (โปรโมชันในบทที่ 3) ตรงที่มันไม่ได้เกิดจาก “input ที่ซับซ้อนขึ้น” แต่เกิดจาก “เวลาที่ผ่านไปเฉยๆ โดยไม่มีใครทำอะไรเลย”
จุดที่น่าสนใจคือนโยบายนี้ประกอบด้วยสองส่วนที่ควรอยู่คนละวง:
- กฎ — ตัวเลข “5 นาที” และ “สิ่งที่เกิดขึ้นเมื่อครบกำหนด” (ยกเลิกออเดอร์) เป็นการตัดสินใจทางธุรกิจล้วนๆ ไม่ต่างจาก invariant “ห้ามตะกร้าว่าง” ที่
Order.Place(...)รักษามาตั้งแต่บทที่ 2 เลย — มันควรอยู่ใน Domain หรือ Application เพราะเป็นความจริงของธุรกิจ ไม่ใช่รายละเอียดทางเทคนิค - นาฬิกา/ตัวจับเวลา — กลไกที่บอกว่า “ตอนนี้เวลาเท่าไร” และ “ตั้งเวลาให้ทำงานถัดไปยังไง” เป็นเรื่องของโครงสร้างพื้นฐาน ไม่ใช่กฎธุรกิจ —
DateTime.UtcNowหรือ background job ที่ใช้จริงเปลี่ยนเทคโนโลยีได้ (เช่น สลับจาก in-process timer ไปเป็น background job library) โดยที่ตัวเลข “5 นาที” ไม่ต้องเปลี่ยนตามเลย
ถ้าเผลอเขียน DateTime.UtcNow ตรงๆ ลงไปใน Order หรือ PlaceOrderHandler (เหมือนที่โปรโมชัน “ลดพิเศษช่วงเที่ยง” ในบทที่ 3 เคยเผลอเรียก DateTime.Now.Hour ตรงๆ) จะเกิดปัญหาเดียวกับที่เกิดไปแล้ว — code วงในจะผูกติดกับนาฬิกาของเครื่องจริง ทดสอบยาก (จะ test “ครบ 5 นาทีแล้ว” ยังไงถ้าต้องรอ 5 นาทีจริงๆ) และเปลี่ยนกลไกจับเวลาทีหลังไม่ได้โดยไม่แตะกฎธุรกิจ ทางแก้จึงเหมือนกับที่ทำมาตลอดคอร์สนี้ทุกครั้ง — ห่อมันเป็น port ให้วงในประกาศ แล้วให้วงนอก implement:
// FoodOrdering.Application/Time/IClock.cs — port: "เวลาปัจจุบันของระบบคือเท่าไร"public interface IClock{ DateTimeOffset UtcNow { get; }}
// FoodOrdering.Application/Time/IScheduler.cs — port: "อีกเท่านี้ ให้เรียกงานนี้ให้หน่อย"public interface IScheduler{ void ScheduleAfter(TimeSpan delay, Func<CancellationToken, Task> action);}// FoodOrdering.Infrastructure/Time/SystemClock.cs — adapter ที่คุยกับนาฬิกาของเครื่องจริงpublic sealed class SystemClock : IClock{ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;}
// FoodOrdering.Infrastructure/Time/TimerScheduler.cs — adapter อย่างง่ายด้วย System.Threading.Timer// (พรีวิวเท่านั้น — คอร์สถัดไปในซีรีส์ที่ว่าด้วยเคสซับซ้อน C1–C3 จะสลับไปใช้ background job library จริง)public sealed class TimerScheduler : IScheduler{ public void ScheduleAfter(TimeSpan delay, Func<CancellationToken, Task> action) => _ = new Timer(_ => action(CancellationToken.None), null, delay, Timeout.InfiniteTimeSpan);}แล้วผูกทั้งสองเข้ากับ composition root เดียวกับที่ผูก IOrderRepository/IMenuCatalog/IPaymentGateway ไปแล้วข้างบน:
// FoodOrdering.Web/Program.cs — เติมต่อจาก block ลงทะเบียนก่อนหน้านี้ในบทนี้builder.Services.AddSingleton<IClock, SystemClock>();builder.Services.AddSingleton<IScheduler, TimerScheduler>();IClock และ IScheduler ไม่ได้ผูกกับ request หรือ transaction ใดๆ เป็นพิเศษเหมือน IOrderRepository ที่ผูกกับ DbContext ต่อ request หนึ่งครั้ง — นาฬิกาของเครื่องเป็นของกลางที่ทุก request ใช้ร่วมกันได้อย่างปลอดภัยตลอดอายุ app จึงลงทะเบียนแบบ AddSingleton แทน AddScoped
// อย่าทำแบบนี้ — เลข "5 นาที" (กฎธุรกิจ) กับ DateTime.UtcNow (กลไกจับเวลา) ถูกฝังปนกันไว้ใน Infrastructurepublic sealed class TimeoutCancellationJob{ public bool ShouldCancel(DateTimeOffset placedAt) => DateTimeOffset.UtcNow - placedAt > TimeSpan.FromMinutes(5);}code ข้างบนผิดสองต่อ: หนึ่ง — เลข “5 นาที” ซึ่งเป็นกฎธุรกิจไปฝังอยู่ใน FoodOrdering.Infrastructure แทนที่จะอยู่วงใน สอง — มันเรียก DateTimeOffset.UtcNow ตรงๆ แทนที่จะผ่าน IClock ทำให้ test เรื่อง “ครบ 5 นาทีหรือยัง” ต้องพึ่งนาฬิกาจริงของเครื่องเสมอ (จะ test ยังไงถ้าต้องรอ 5 นาทีจริงๆ ทุกครั้ง) ทางที่ถูกคือให้ทั้งค่าคงที่ 5 นาทีและตรรกะตัดสินใจแบบนี้ย้ายไปอยู่วงในเป็นส่วนหนึ่งของ Domain/Application ที่รับ IClock เข้ามาผ่าน constructor (เหมือนที่ PlaceOrderHandler ถาม IMenuCatalog/IPaymentGateway อยู่แล้ว) แทนที่จะปล่อยให้ Infrastructure ตัดสินใจเรื่องนี้เอง
บทนี้ตั้งใจแสดงแค่ seam ของ C2 — จุดต่อที่กฎ (5 นาที, ยกเลิกอัตโนมัติ) กับกลไก (IClock/IScheduler) ควรแยกจากกัน ยังไม่ลงรายละเอียดว่า state machine ของสถานะออเดอร์ (Placed → PendingConfirmation → Confirmed/Cancelled) หน้าตาเป็นยังไง หรือ Order ควรมี property/method อะไรเพิ่มเพื่อรองรับมัน — นั่นเป็นเนื้อหาที่ลึกกว่าขอบเขตของคอร์สที่เน้นโครงสร้าง Clean Architecture คอร์สนี้ คอร์สถัดไปในซีรีส์ (ที่ว่าด้วยเคสซับซ้อน C1–C3) จะกลับมาแกะ C2 นี้อีกครั้งแบบเต็มๆ พร้อม state machine จริง
สรุป + สิ่งที่จะสร้างต่อ
หัวข้อที่มีชื่อว่า “สรุป + สิ่งที่จะสร้างต่อ”บทนี้เราหยุดมอง Program.cs ทั้ง file เป็นครั้งแรกในฐานะ composition root — จุดเดียวในทั้งโซลูชันที่รู้จักทั้ง port ที่วงในประกาศไว้ (IOrderRepository, IMenuCatalog, IPaymentGateway, IClock, IScheduler) และ adapter จริงที่วงนอก implement (EfOrderRepository, StaticMenuCatalog, MockPaymentGateway, SystemClock, TimerScheduler) พร้อมกันทั้งหมด ผูกเข้าด้วยกันผ่าน dependency injection ตามหลัก Inversion of Control และ Hollywood Principle ที่ให้ container เป็นฝ่ายเรียก code ของเรา ไม่ใช่ code ของเราวิ่งไปเสาะหา dependency เอง และสุดท้ายคือพรีวิวของ C2 — seam ของนโยบาย timeout ที่สอนบทเรียนเดิมซ้ำอีกครั้งในบริบทใหม่: กฎธุรกิจ (5 นาที, ยกเลิกอัตโนมัติ) อยู่วงใน ส่วนกลไกที่บอกเวลา (IClock/IScheduler) เป็นแค่ adapter อีกคู่หนึ่งที่มาบรรจบกันที่ composition root เดียวกันนี้
บทถัดไปเราจะพิสูจน์สิ่งที่พูดมาตลอดหกบทว่าเป็นจริง — เขียน test ที่แสดงให้เห็นว่า Domain กับ Application ทดสอบได้โดยไม่ต้องพึ่ง ASP.NET Core, EF Core หรือฐานข้อมูลจริงเลยสักบรรทัด เพราะสิ่งเดียวที่รู้จักทั้งสองฝั่งพร้อมกันคือ composition root ที่เราเพิ่งประกอบเสร็จในบทนี้
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Dependency Injection — เทคนิคส่ง dependency เข้ามาจากภายนอกแทนที่จะให้ class สร้างขึ้นเอง รากของทุกบรรทัด
AddScoped/AddSingleton - Inversion of Control — หลักที่กลับทิศการควบคุมให้ container/framework เป็นผู้เรียก code ของเรา แทนที่ code ของเราจะเรียก library เอง
- Hollywood Principle — “อย่าโทรหาเรา เดี๋ยวเราโทรหาคุณ” ชื่อเล่นที่จำง่ายของ IoC
- Hexagonal Architecture (Ports and Adapters) — บทความเต็มของ Cockburn ที่มองภาพเดียวกันนี้ผ่านรูปหกเหลี่ยม core อยู่กลาง ล้อมด้วย port/adapter
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3composition root คืออะไร?