重新設計:以 DI 改寫電商應用程式#
本章將第二章的緊耦合電商應用程式,運用 DI 原則從頭改寫。開發方式從 inside-out 改為 outside-in——從 UI 層開始思考需要什麼,逐步向內定義介面與實作。
Outside-in 開發方式與 YAGNI (You Ain’t Gonna Need It) 原則高度相關:從使用端出發,只定義真正需要的介面和功能,避免過度設計。
Dependency Inversion 的實踐#
改寫後最關鍵的變化是依賴方向反轉:
- 改寫前:Domain 層依賴資料存取層
- 改寫後:資料存取層依賴 Domain 層
IProductRepository 介面定義在 Domain 層中,SqlProductRepository 實作在資料存取層中。資料存取層引用 Domain 層的介面,而非反過來。

Figure 3.2: Mary 應用程式的理想依賴反轉
// 定義在 Domain 層
public interface IProductRepository
{
IEnumerable<Product> GetFeaturedProducts();
}
// 實作在 Data Access 層
public class SqlProductRepository : IProductRepository
{
private readonly CommerceContext context;
public SqlProductRepository(CommerceContext context)
{
this.context = context;
}
public IEnumerable<Product> GetFeaturedProducts()
{
return context.Products
.Where(p => p.IsFeatured)
.ToList();
}
}改寫後的類別結構#
改寫後的系統包含九個類別和三個介面(相較於 Mary 的四個類別)。雖然數量增加了,但每個類別的職責更加單一明確:
UI 層#
HomeController——透過 Constructor Injection 接收ProductService,不再自行建立依賴ProductViewModel/FeaturedProductsViewModel——純粹的 POCO,僅用於 View 的資料呈現AspNetUserContextAdapter——Adapter Pattern,將HttpContext包裝為IUserContext
Domain 層#
ProductService——核心業務邏輯,透過 Constructor Injection 接收IProductRepository和IUserContextDiscountedProduct——封裝折扣計算邏輯IProductRepository——資料存取的 AbstractionIUserContext——使用者資訊的 Abstraction
Data Access 層#
SqlProductRepository——實作IProductRepositoryCommerceContext——Entity Framework Core 的 DbContext

Figure 3.3: 本章結束時的類別與介面總覽,虛線表示介面
核心 DI 模式的運用#
Constructor Injection#
最主要的依賴注入方式。ProductService 在建構時宣告它需要的所有依賴:
public class ProductService
{
private readonly IProductRepository repository;
private readonly IUserContext userContext;
public ProductService(
IProductRepository repository,
IUserContext userContext)
{
this.repository = repository;
this.userContext = userContext;
}
}
Figure 3.8: ProductService 與其依賴

Figure 3.10: ProductService 不再直接依賴 SqlProductRepository,兩者都依賴於 Abstraction
Method Injection#
適用於依賴會隨每次呼叫而不同的情境:
public class Product
{
public DiscountedProduct ApplyDiscountFor(
IUserContext userContext)
{
// 根據使用者身份計算折扣
}
}Adapter Pattern#
AspNetUserContextAdapter 將框架特定的 HttpContext 適配為 Domain 層定義的 IUserContext,確保 Domain 層不依賴 ASP.NET Core:
public class AspNetUserContextAdapter : IUserContext
{
private readonly IHttpContextAccessor accessor;
public AspNetUserContextAdapter(
IHttpContextAccessor accessor)
{
this.accessor = accessor;
}
public bool IsInRole(Role role)
{
return accessor.HttpContext.User
.IsInRole(role.ToString());
}
}Composition Root#
所有物件的組裝都集中在 Composition Root——ASP.NET Core 的 Startup 類別中。這是整個應用程式中唯一知道所有具體實作的地方:
// Startup.cs - Composition Root
public void ConfigureServices(IServiceCollection services)
{
// Pure DI:手動組裝物件圖
services.AddTransient<IProductRepository>(
sp => new SqlProductRepository(
new CommerceContext(connectionString)));
services.AddTransient<IUserContext>(
sp => new AspNetUserContextAdapter(
sp.GetRequiredService<IHttpContextAccessor>()));
services.AddTransient<ProductService>();
}Composition Root 是應用程式啟動時組裝整個物件圖的單一位置。它應該盡可能靠近應用程式的進入點 (Entry Point),且整個應用程式只有一個 Composition Root。
折扣邏輯回歸 Domain 層#
在第二章中散落在 UI 層的折扣邏輯,現在正確地封裝在 Domain 層的 DiscountedProduct 類別中。UI 層只負責呈現結果,不再參與業務計算。
View Models 作為 POCOs#
ProductViewModel 和 FeaturedProductsViewModel 是純粹的 POCO (Plain Old CLR Object),只包含屬性,沒有行為邏輯。它們作為 Domain 層和 View 之間的資料傳輸媒介,確保 View 不需要直接接觸 Domain 物件。
改寫帶來的好處#
| 改善前 | 改善後 |
|---|---|
| 資料存取無法替換 | 實作 IProductRepository 即可切換 |
| 無法 Unit Test | 注入 Test Double 即可隔離測試 |
| 關注點混雜 | 每個類別職責單一 |
| 必須等資料層完成 | 各層可針對介面平行開發 |
| 折扣邏輯在 UI 層 | 折扣邏輯在 Domain 層 |

Figure 3.15: 套用 DI 後的電商應用程式依賴圖
改寫後的類別數量從 4 個增加到 12 個(9 個類別 + 3 個介面),但整體的可維護性、可測試性與靈活性大幅提升。更多的類別意味著更細的職責劃分,而非更高的複雜度。