.NET 8 具名注入(Keyed Services)優點與常見場景
.NET 8 具名注入(Keyed Services)優點與常見場景
前言
.NET 的內建 DI 容器有一個長年的限制:一個介面對應多個實作時,沒辦法「指定要哪一個」。
services.AddScoped<IPaymentProvider, EcPayProvider>();
services.AddScoped<IPaymentProvider, NewebPayProvider>();
// 建構子注入 IPaymentProvider 時,只會拿到「最後註冊的那個」(NewebPayProvider)
在 .NET 8 之前,遇到這種需求只能繞路:注入 IEnumerable<T> 自己篩選、或自己寫工廠類別。
.NET 8 的 Microsoft.Extensions.DependencyInjection 終於內建了具名注入(Keyed Services):
註冊時給每個實作一個 key,取用時憑 key 拿到指定的實作。
這篇整理它的基本用法、相較舊做法的優點,以及兩個我覺得最常用到的場景:
各家金流提供者、同介面的不同儲存策略。
沒有 Keyed Services 之前,我們怎麼繞
繞路的寫法主要兩種,先並排看:
::: code-group
public class PaymentService
{
private readonly IEnumerable<IPaymentProvider> _providers;
public PaymentService(IEnumerable<IPaymentProvider> providers)
{
_providers = providers;
}
public IPaymentProvider Resolve(string type)
{
// 介面被迫多一個 Type 屬性,純粹為了讓這裡認得出來
return _providers.First(provider => provider.Type == type);
}
}public class PaymentProviderFactory
{
private readonly IServiceProvider _serviceProvider;
public PaymentProviderFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IPaymentProvider Create(string type) => type switch
{
"EcPay" => _serviceProvider.GetRequiredService<EcPayProvider>(),
"NewebPay" => _serviceProvider.GetRequiredService<NewebPayProvider>(),
_ => throw new NotSupportedException($"不支援的金流:{type}")
};
}:::
| 面向 | IEnumerable<T> 自己挑 | 自製工廠 |
|---|---|---|
| 介面乾淨度 | ❌ 被迫多一個 Type 屬性,跟「付款」無關 | ✅ 介面不用動 |
| 實例建立 | ❌ 全部實作都被 new 出來(連帶各自的相依) | ✅ 用到才建立 |
| 加一家金流 | 加實作+註冊即可 | ❌ 還要回頭改工廠的 switch |
| 額外成本 | 無 | 工廠類別本身,且具體類別得再註冊一次 |
兩條路各瘸一隻腳。工廠那條路尤其冤——「憑條件給出對應實作」本來就是容器該做的事,
我們只是在幫它代工。
.NET 8 的做法:三個新朋友
Keyed Services 的 API 就三組,跟原本的註冊方法一一對應:
| 原本 | Keyed 版本 |
|---|---|
AddSingleton | AddKeyedSingleton |
AddScoped | AddKeyedScoped |
AddTransient | AddKeyedTransient |
註冊——多一個 key 參數(型別是 object,字串、enum 都行):
builder.Services.AddKeyedScoped<IPaymentProvider, EcPayProvider>("EcPay");
builder.Services.AddKeyedScoped<IPaymentProvider, NewebPayProvider>("NewebPay");
builder.Services.AddKeyedScoped<IPaymentProvider, LinePayProvider>("LinePay");取用——兩種方式:
::: code-group
// 建構子(或 Minimal API 參數)上掛 attribute,編譯期就決定要哪個
public class CheckoutService
{
public CheckoutService([FromKeyedServices("EcPay")] IPaymentProvider provider) { }
}// 執行期才知道 key(例如看訂單資料)時,透過 IServiceProvider 拿
var provider = serviceProvider.GetRequiredKeyedService<IPaymentProvider>("EcPay");:::
生命週期規則跟原本完全一樣——AddKeyedScoped 的實例在同一個 request 內共用,以此類推。
優點整理
- 介面保持乾淨——不再需要
Type這種「給篩選用」的屬性,實作類別只管做好自己的事。 - 用到才建立——憑 key 只會建立那一個實作,不像
IEnumerable<T>全家都 new 出來。 - 不用自己寫工廠——選擇邏輯收回容器,加一家金流只要多一行
AddKeyedScoped,不用改 switch。 - 是官方內建——不用為了這個需求引入 Autofac 等第三方容器(Named/Keyed registration 是很多人換容器的理由之一)。
- 同介面可以「keyed + 非 keyed」並存——這點很妙,下面第二個場景會用到。
場景一:各家金流提供者
電商後端幾乎都會同時接多家金流:綠界(ECPay)、藍新(NewebPay)、LINE Pay……
它們的介面長得一樣(建立交易、查詢、退款),但哪一筆訂單走哪一家,是執行期才知道的——由使用者結帳時選擇。
key 用 enum 比字串安全(打錯字編譯就會抓到):
public enum PaymentProviderType
{
/// <summary> 未知(預設值,永遠不會註冊成 key,用意後面說明) </summary>
Unknown = 0,
/// <summary> 綠界 </summary>
EcPay = 1,
/// <summary> 藍新 </summary>
NewebPay = 2,
/// <summary> LINE Pay </summary>
LinePay = 3,
}// Program.cs / DependencyInjection.cs
builder.Services.AddKeyedScoped<IPaymentProvider, EcPayProvider>(PaymentProviderType.EcPay);
builder.Services.AddKeyedScoped<IPaymentProvider, NewebPayProvider>(PaymentProviderType.NewebPay);
builder.Services.AddKeyedScoped<IPaymentProvider, LinePayProvider>(PaymentProviderType.LinePay);使用者選好金流後,Controller 到 Service 怎麼接
實際的呼叫鏈長這樣:
使用者在結帳頁選「LINE Pay」→ 前端把選擇放進 request
→ Controller 原封不動轉交 → Service 憑選擇跟容器要實作 →LinePayProvider出場
把三層攤開來看:
::: code-group
/// <summary> 建立付款交易請求 </summary>
public class CreateTransactionRequestViewModel
{
/// <summary> 訂單編號 </summary>
public string OrderNo { get; set; } = string.Empty;
/// <summary> 使用者選擇的金流 </summary>
public PaymentProviderType PaymentProviderType { get; set; }
}[Route("api/payment")]
public class PaymentController : ControllerBase
{
private readonly IPaymentService _paymentService;
public PaymentController(IPaymentService paymentService)
{
_paymentService = paymentService;
}
/// <summary> 建立付款交易 </summary>
[HttpPost("transaction")]
public async Task<IActionResult> CreateTransaction(CreateTransactionRequestViewModel request)
{
// Controller 不挑金流,只把「使用者的選擇」原封不動交給 Service
var result = await _paymentService.CreateTransactionAsync(
request.OrderNo, request.PaymentProviderType);
return Ok(result);
}
}public class PaymentService : IPaymentService
{
private readonly IServiceProvider _serviceProvider;
public PaymentService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<PaymentResult> CreateTransactionAsync(
string orderNo, PaymentProviderType providerType)
{
// 憑使用者的選擇,跟容器要對應的金流實作——選 LinePay 就拿到 LinePayProvider
var provider = _serviceProvider
.GetRequiredKeyedService<IPaymentProvider>(providerType);
return await provider.CreateTransactionAsync(orderNo);
}
}:::
注意這條鏈上的分工:
- Controller 完全不知道有幾家金流。它不用
switch、不用 if,甚至不用知道IPaymentProvider的存在——
只負責把使用者的選擇往下傳。所以是一個統一的/api/payment/transaction,
而不是每家金流各開一個 Controller。 - 挑實作的動作只發生在 Service 那一行
GetRequiredKeyedService,
而且它挑的依據就是 enum 本身,不需要任何轉換或對照表。 - 前端傳來的值由模型繫結把關。enum 預設吃 JSON 數字;想讓前端直接傳
"LinePay"這種字串,
在AddControllers().AddJsonOptions(...)裡加上JsonStringEnumConverter即可——
傳了不存在的值,繫結階段就會擋下 400,根本進不到 Service。
之後要接第四家金流(例如 TapPay),只要:新增 enum 成員、寫一個 TapPayProvider、加一行註冊。
ViewModel、Controller、Service 一個字都不用改——這就是開放封閉原則(OCP)想要的樣子。
有一種漏網之魚:enum 成員加了、註冊卻忘了加
這時值能通過模型繫結,但 GetRequiredKeyedService 會在執行期丟 InvalidOperationException。
如果想把這種情況變成友善的 400 而不是 500,可以改用 GetKeyedService(不帶 Required)
判 null 後自行回錯誤訊息。
為什麼 enum 要保留 Unknown = 0
假如前端漏傳這個欄位時,模型繫結不會報錯,而是給 enum 預設值 0。這時 0 是誰就很關鍵:
- 0 是
Unknown——永遠不會被註冊成 key,上面那道判null的防線剛好把漏傳擋下來。 - 0 是
EcPay——漏傳就會默默走綠界金流,付款照常成功,這種 bug 通常使人崩潰。

有些預設值機制是充滿陷阱的!
補充:有人會覺得注入
IServiceProvider是 Service Locator 反模式。
我的看法是——當「選哪個實作」本來就是執行期資料決定的,這裡的IServiceProvider
扮演的就是工廠角色,跟到處亂拿服務的濫用是兩回事。介意的話,
也可以再包一層IPaymentProviderFactory讓意圖更明確,內部一樣用 keyed 取。
延伸問題:PaymentProviderType 該放在哪一層?
把這個模式套進分層架構(API → Services → Domain,Infrastructure → Domain)時,
第一個會卡住的問題通常是:這個 enum 是金流的東西,是不是該跟 Provider 實作一起放 Infrastructure?
答案是 Domain。關鍵在於認清「第三方服務」其實是兩半:
| 東西 | 放哪層 | 角色 |
|---|---|---|
IPaymentProvider 介面 | Domain(ProviderContracts) | 契約:定義「能做什麼」 |
PaymentProviderType enum | Domain(DomainModels) | 契約:定義「有哪幾家可選」 |
EcPayProvider 等實作 | Infrastructure(Providers) | 血肉:打 HTTP、簽章、讀設定 |
AddKeyedScoped 註冊 | API 層(組合根) | 把契約跟血肉接起來的地方 |
「第三方服務放 Infra」指的是血肉那一半——會打 HTTP、碰 SDK、讀金鑰設定的實作類別。
enum 屬於契約那一半,理由有兩個:
- enum 是「選項清單」,不是「實作細節」。「本系統支援綠界、藍新、LINE Pay」是業務知識,
跟介面定義「能做什麼」是同一國的;綠界的 API 怎麼簽章才是 Infrastructure 的事。
而且它不只是 DI 的 key——「這筆訂單走哪家金流」會存進資料庫、出現在報表上,
本來就是領域資料。 - 依賴方向也不允許放別層。Services 看不到 Infrastructure,enum 放 Infra 的話,
Service 那行GetRequiredKeyedService<IPaymentProvider>(providerType)的參數型別就拿不到;
放 API 層則反過來,Services 的依賴方向到不了。它的使用者(ViewModel、Service、DI 註冊、DomainModel)
橫跨三層,唯一大家都看得到的就是 Domain。
好記的判斷法:把某家金流整個抽掉時會消失的東西,放 Infrastructure——
EcPayProvider、它的 ConfigModel、簽章工具,抽掉綠界就全沒了;
抽掉任何一家都還必須存在的東西,放 Domain——就算把金流商全換掉,
「系統有多家金流可選」這個概念(介面與 enum)還是在。
場景二:同介面的不同儲存策略(我專案裡的真實案例)
這是我自己後端專案裡的用法。Refresh Token 的儲存有兩個實作:
- 資料庫版:持久化,重開機不掉
- 快取版(Redis):快,但可能被淘汰
實際上線想要的是「混合」:寫入時兩邊都寫、讀取時快取優先、快取沒中再回資料庫。
混合版自己也實作同一個介面,並且組合另外兩個——這時 Keyed Services 就派上用場了:
// 兩個「零件」用 key 註冊
services.AddKeyedScoped<IPassengerRefreshTokenRepository, PassengerRefreshTokenDatabaseRepository>("database");
services.AddKeyedScoped<IPassengerRefreshTokenRepository, PassengerRefreshTokenCacheRepository>("cache");
// 「成品」用一般註冊——全專案注入 IPassengerRefreshTokenRepository 拿到的都是混合版
services.AddScoped<IPassengerRefreshTokenRepository, PassengerRefreshTokenHybridRepository>();/// <summary> 乘客 Refresh Token 混合儲存庫實作(快取優先,資料庫持久化) </summary>
public class PassengerRefreshTokenHybridRepository : IPassengerRefreshTokenRepository
{
private readonly IPassengerRefreshTokenRepository _databaseRepository;
private readonly IPassengerRefreshTokenRepository _cacheRepository;
public PassengerRefreshTokenHybridRepository(
[FromKeyedServices("database")] IPassengerRefreshTokenRepository databaseRepository,
[FromKeyedServices("cache")] IPassengerRefreshTokenRepository cacheRepository)
{
_databaseRepository = databaseRepository;
_cacheRepository = cacheRepository;
}
// 寫入:先進資料庫,成功後再進快取
// 讀取:快取優先,沒中再回資料庫
}這個寫法漂亮的地方在於:
- 三個類別實作同一個介面卻不打架——keyed 註冊對一般的
GetService<T>()是「隱形」的,
所以不掛 attribute 的注入點只會拿到混合版,不會誤拿到零件。 - 裝飾器/組合模式不用工廠就組得起來——.NET 8 之前要做這件事,
得用 factory delegate 手動new混合版、手動餵零件進去。 - 使用端完全不知道背後有三個實作,要換策略只動註冊那三行。
如果沒有 Keyed Services,同一個介面註冊三次,容器只認最後一個,前兩個等於白註冊。
其他常見場景
模式都一樣——「同一個介面、多個實作、由設定或執行期資料決定用哪個」:
| 場景 | key 的例子 |
|---|---|
| 通知渠道 | "email"、"sms"、"push" |
| 檔案儲存 | "local"、"s3"、"minio" |
| 匯出格式 | "pdf"、"excel"、"csv" |
| 多組快取設定 | "big"(長 TTL)、"small"(短 TTL) |
| 多租戶/多環境 | 租戶代碼、"sandbox" / "production" |
注意事項
- key 找不到是執行期錯誤。
GetRequiredKeyedService拿不到會丟InvalidOperationException。
key 建議用 enum 或常數類別集中管理,避免魔法字串散落各處打錯字。 - keyed 與非 keyed 是兩個世界。
GetService<T>()看不到 keyed 註冊;GetKeyedService<T>(key)也拿不到非 keyed 的註冊。這是特性(場景二靠它),但第一次用容易困惑。 - .NET 8 的 Middleware 建構子不支援
[FromKeyedServices],.NET 9 才補上。
在 .NET 8 的 middleware 裡要用的話,改注入IServiceProvider動態取。 KeyedService.AnyKey可以當萬用註冊——AddKeyedScoped<IFoo, DefaultFoo>(KeyedService.AnyKey)
會回應任何 key 的請求,適合做 fallback,但小心它會蓋掉「key 打錯應該要炸」的保護。- 需要
Microsoft.Extensions.DependencyInjection8.0 以上;
ASP.NET Core 8 專案內建就是,class library 記得把套件跟著升。
小結
三種做法最後放在一起看:
IEnumerable<T> 篩選 | 自製工廠 | Keyed Services | |
|---|---|---|---|
| 介面乾淨度 | ❌ 要加 Type 屬性 | ✅ | ✅ |
| 實例建立 | ❌ 全部都建 | ✅ 用到才建 | ✅ 用到才建 |
| 加新實作 | 加實作+註冊 | ❌ 還要改 switch | 加一行註冊 |
| 額外類別 | 無 | ❌ 工廠類別 | 無 |
| 版本需求 | 不限 | 不限 | .NET 8+ |
.NET 8 的 Keyed Services 補上了內建 DI 容器長年缺的一角:同介面多實作,憑 key 指定。
金流提供者是最典型的場景——key 用 enum、依訂單動態取,加新金流不改呼叫端;
混合儲存庫則展示了另一種威力——keyed 註冊零件、非 keyed 註冊成品,組合模式不需要工廠就組得起來。
唯二要留意的:keyed 與非 keyed 互相隱形的特性(是特性也是陷阱),
以及 key 找不到是執行期才會炸的風險——key 記得用 enum 或常數集中管理。