重新設計:以 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 接收 IProductRepositoryIUserContext
  • DiscountedProduct——封裝折扣計算邏輯
  • IProductRepository——資料存取的 Abstraction
  • IUserContext——使用者資訊的 Abstraction

Data Access 層#

  • SqlProductRepository——實作 IProductRepository
  • CommerceContext——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#

ProductViewModelFeaturedProductsViewModel 是純粹的 POCO (Plain Old CLR Object),只包含屬性,沒有行為邏輯。它們作為 Domain 層和 View 之間的資料傳輸媒介,確保 View 不需要直接接觸 Domain 物件。

改寫帶來的好處#

改善前改善後
資料存取無法替換實作 IProductRepository 即可切換
無法 Unit Test注入 Test Double 即可隔離測試
關注點混雜每個類別職責單一
必須等資料層完成各層可針對介面平行開發
折扣邏輯在 UI 層折扣邏輯在 Domain 層

Figure 3.15: 套用 DI 後的電商應用程式依賴圖

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