Factory Method trong C#: Thay Switch Bằng Keyed DI
Factory Method pattern trong C# / .NET 10: khi switch ladder phình ra mất kiểm soát, khi keyed DI thay thế bản GoF, và khi vẫn cần cả hai.
Mục lục
- Factory Method giải quyết bài toán gì trong C#?
- Bản Gang of Four hoạt động ra sao?
- Thay nó bằng AddKeyedScoped() trong .NET 8+ thế nào?
- Khi nào nên giữ factory class tự cuộn?
- So sánh Factory Method với Strategy và Abstract Factory thế nào?
- Một ví dụ thật trong C# / .NET 10 trông thế nào?
- Đọc tiếp gì trong series?
User chọn payment method ở trang checkout — Stripe, PayPal, hay Cash on
Delivery — và bấm Pay. Sáu tháng sau, CheckoutController của bạn có
một câu switch ba nhánh. Mười hai tháng sau, cái switch ấy có mặt ở
ba controller, với apple-pay và bank-transfer được dán thêm cuối hai
chỗ nhưng không phải chỗ thứ ba. Một bug ticket bay vào vì đơn COD bị
bỏ qua một call kiểm tra fraud nằm ở nhánh thứ tư — chỉ trong controller
web; API mobile vẫn chạy bình thường.
Đây là triệu chứng kéo Factory Method vào hộp đồ nghề. Ý đồ một dòng:
để một phần code riêng quyết class concrete nào khởi tạo, caller không
phải bận tâm. Lý do bài này dài hơn một dòng là vì câu trả lời C# hiện
đại cho "class concrete nào?" gần như không bao giờ trông giống hình vẽ
trong sách Gang of Four. Nó trông như một-hai dòng Program.cs và một
tham số constructor — và nhận ra cái đó cũng là Factory Method mới
là việc.
Chúng ta tiếp tục với checkout e-commerce từ chương
Singleton, nhưng object dùng chung không
còn là câu hỏi nữa. Câu hỏi là: cho một chuỗi payment method trong
request, làm sao có đúng IPaymentProcessor mà không cần switch?
Factory Method giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi caller biết nó cần object thuộc một abstraction nào đó, nhưng không biết — và không nên cần biết — class concrete nào để khởi tạo. Ba hình dạng cụ thể:
- Tập đóng các biến thể đứng sau cùng một interface. Hôm nay ba
payment processor, quý sau sáu. Thêm cái thứ bảy không được phép ép
bạn đi tìm mọi
switchtrong codebase. - Khởi tạo có chi phí không tầm thường.
StripeProcessorđọc API key từ config;PaypalProcessormở HTTP client. Trải mấy việc đó ra khắp các caller là công thức cho disposal sót và xử lý lỗi không nhất quán. - Lựa chọn phụ thuộc dữ liệu runtime. Payment method của user là string trong request body. Hiểu biết static "class nào" sống ở mức sai — phải có chỗ ánh xạ runtime từ key sang type.
Cái không phải bài toán Factory Method: "Tôi muốn share một
PriceCatalog cho cả app". Đó là Singleton
pattern. Factory tạo; không phải share.
Bản Gang of Four hoạt động ra sao?
Mô tả 1994 chia thế giới thành Creator và Product. Mỗi subclass Creator
override method Create() và trả Product khác nhau. Dịch sang sketch
payment processor:
classDiagram
class IPaymentProcessor {
<<interface>>
+PayAsync(amount) PaymentResult
}
class StripeProcessor
class PaypalProcessor
class CodProcessor
IPaymentProcessor <|.. StripeProcessor
IPaymentProcessor <|.. PaypalProcessor
IPaymentProcessor <|.. CodProcessor
class IPaymentProcessorFactory {
<<interface>>
+Create(method) IPaymentProcessor
}
class CheckoutHandler
CheckoutHandler ..> IPaymentProcessorFactory : hỏi
IPaymentProcessorFactory ..> IPaymentProcessor : trả về
Đoạn C# nguyên si bạn sẽ viết nếu dịch sách GoF:
public interface IPaymentProcessor
{
Task<PaymentResult> PayAsync(decimal amount, CancellationToken ct = default);
}
public sealed class StripeProcessor : IPaymentProcessor { /* ... */ }
public sealed class PaypalProcessor : IPaymentProcessor { /* ... */ }
public sealed class CodProcessor : IPaymentProcessor { /* ... */ }
public interface IPaymentProcessorFactory
{
IPaymentProcessor Create(string method);
}
public sealed class PaymentProcessorFactory : IPaymentProcessorFactory
{
private readonly IServiceProvider _sp;
public PaymentProcessorFactory(IServiceProvider sp) => _sp = sp;
public IPaymentProcessor Create(string method) => method switch
{
"stripe" => _sp.GetRequiredService<StripeProcessor>(),
"paypal" => _sp.GetRequiredService<PaypalProcessor>(),
"cod" => _sp.GetRequiredService<CodProcessor>(),
_ => throw new ArgumentException($"Unknown payment method: {method}")
};
}
Cái này chạy được, đôi khi vẫn là câu trả lời đúng — xem mục Khi nào nên giữ factory class tự cuộn? phía dưới. Nhưng để ý phần lớn body factory chỉ là ánh xạ máy móc. Ngôn ngữ C# và DI container của .NET đã có tính năng xoá gần hết phần đó.
Thay nó bằng AddKeyedScoped() trong .NET 8+ thế nào?
Từ .NET 8, Microsoft.Extensions.DependencyInjection đã hỗ trợ keyed
services native. Mỗi đăng ký mang một key — string, enum, bất kỳ
object — và container resolve đúng implementation theo key lúc inject.
Class PaymentProcessorFactory phía trên biến mất hoàn toàn:
// Program.cs ----------------------------------------------------
builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PaypalProcessor>("paypal");
builder.Services.AddKeyedScoped<IPaymentProcessor, CodProcessor>("cod");
// Caller --------------------------------------------------------
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly IKeyedServiceProvider _sp;
public CheckoutController(IKeyedServiceProvider sp) => _sp = sp;
[HttpPost("{method}/pay")]
public async Task<IActionResult> Pay(string method, decimal amount, CancellationToken ct)
{
var processor = _sp.GetKeyedService<IPaymentProcessor>(method);
if (processor is null) return BadRequest($"Unknown payment method: {method}");
return Ok(await processor.PayAsync(amount, ct));
}
}
Ba điều cần để ý:
Không có class factory nào. Container chính là factory. Ý đồ Factory Method — caller chọn theo key, implementation đúng tới tay — vẫn nguyên. Boilerplate biến mất.
Scopedthường là đúng ở đây. Mỗi request có riêng một instanceStripeProcessor, đúng lifetime khi processor giữ state per-request (idempotency key, cancellation token, logger scope). Chỉ dùngAddKeyedSingleton<T>()khi implementation thực sự stateless và thread-safe.[FromKeyedServices]cho phép pin key ngay ở tham số khi lựa chọn là cố định cho endpoint đó, ví dụ webhook chỉ-Stripe:[HttpPost("stripe/webhook")] public async Task<IActionResult> StripeWebhook( [FromKeyedServices("stripe")] IPaymentProcessor stripe, WebhookPayload payload) => Ok(await stripe.HandleWebhookAsync(payload));
Doc chính thức của Microsoft về keyed services là tham khảo gốc; API surface nhỏ, đáng năm phút đọc qua.
Cho project còn ở .NET 6 hoặc 7, công thức tương đương là Func<TKey, TProduct> đăng ký trong DI:
// Program.cs ----------------------------------------------------
services.AddScoped<StripeProcessor>();
services.AddScoped<PaypalProcessor>();
services.AddScoped<CodProcessor>();
services.AddScoped<Func<string, IPaymentProcessor>>(sp => method => method switch
{
"stripe" => sp.GetRequiredService<StripeProcessor>(),
"paypal" => sp.GetRequiredService<PaypalProcessor>(),
"cod" => sp.GetRequiredService<CodProcessor>(),
_ => throw new ArgumentException($"Unknown payment method: {method}")
});
Caller inject Func<string, IPaymentProcessor> rồi gọi như method. Đây
là idiom pre-.NET-8 đã có trong codebase production .NET hơn chục năm.
Cũng là công cụ đúng khi muốn bản thân resolver tự log, validate, hoặc
fallback — xem phần kế.
Khi nào nên giữ factory class tự cuộn?
Một class PaymentProcessorFactory thật sự vẫn xứng đáng khi logic chọn
vượt quá thứ một dòng AddKeyedScoped diễn đạt được. Bốn trigger cụ
thể:
- Log mỗi lần resolve. "Cho tôi biết processor nào được chọn cho
order nào, vì sao." Container không cho hook đó. Một class factory với
_log.LogInformation("Selected {P} for {Order}", processor, orderId)thì có. - Validate key. Payment method của user nằm trong tập đóng và bạn
muốn trả về
ProblemDetailskèm danh sách option hợp lệ khi key sai — thay vì để container némInvalidOperationExceptionchung chung. - Tham số khởi tạo theo từng call. Processor cần merchant ID lấy từ
request, không phải từ config. DI resolve type, không phải instance
được tham số hoá theo dữ liệu call. Class factory với chữ ký
Create(string method, string merchantId)sạch hơn hack thread-local. - Rule fallback. "Nếu
paypalkhông khả dụng ở region này, trả vềstripethay thế." Chuỗi fallback thuộc về một class, không thuộc một dòng đăng ký.
Khi phân vân, bắt đầu bằng AddKeyedScoped. Lên class factory ngày dòng
đăng ký mọc ra một comment.
So sánh Factory Method với Strategy và Abstract Factory thế nào?
Ba pattern này làm interviewer và reviewer PR rối não đều. Khác biệt vừa một thẻ index:
| Khía cạnh | Factory Method | Strategy | Abstract Factory |
|---|---|---|---|
| Trả về | Một object bạn sẽ dùng | (Dùng trực tiếp, không "trả về") | Một họ object liên quan |
| Quyết định về | Class concrete nào để khởi tạo | Thuật toán nào để chạy trên input | Họ nào để build |
| Số trục biến đổi | Một | Một | Nhiều, biến đổi cùng nhau |
| Code caller điển hình | var p = factory.Create(key); p.Pay() |
strategy.Apply(state) |
var ui = factory.Build(); ui.AddressForm; ui.ConsentDialog |
| Implementation .NET điển hình | AddKeyedScoped<T>() |
Func<> đăng ký hoặc IStrategy |
Một IFactory cho mỗi family scope |
Quy tắc rõ ràng nhất: nếu caller dùng object trả về đúng một lần rồi
vứt, có lẽ bạn cần Strategy inject thẳng, không phải Factory Method.
Nếu caller giữ object qua nhiều method call (IPaymentProcessor được
dùng cho cả luồng checkout, không phải chỉ một charge), Factory Method
mới là khung đúng.
Một ví dụ thật trong C# / .NET 10 trông thế nào?
Ví dụ đầy đủ có thể commit vào codebase thật. Giả định .NET 8+ cho keyed services.
// Domain ----------------------------------------------------------
public sealed record PaymentResult(string Status, string? TransactionId, string? Error);
public interface IPaymentProcessor
{
Task<PaymentResult> PayAsync(decimal amount, CancellationToken ct);
}
public sealed class StripeProcessor : IPaymentProcessor
{
private readonly HttpClient _http;
private readonly StripeOptions _opts;
public StripeProcessor(HttpClient http, IOptions<StripeOptions> opts)
=> (_http, _opts) = (http, opts.Value);
public async Task<PaymentResult> PayAsync(decimal amount, CancellationToken ct)
{
// POST /v1/charges sang Stripe...
return new PaymentResult("succeeded", "ch_abc123", null);
}
}
public sealed class PaypalProcessor : IPaymentProcessor { /* ... */ }
public sealed class CodProcessor : IPaymentProcessor
{
public Task<PaymentResult> PayAsync(decimal amount, CancellationToken ct)
=> Task.FromResult(new PaymentResult("pending", null, null));
}
// Composition root ------------------------------------------------
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<StripeOptions>(builder.Configuration.GetSection("Stripe"));
builder.Services.AddHttpClient<StripeProcessor>();
builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PaypalProcessor>("paypal");
builder.Services.AddKeyedScoped<IPaymentProcessor, CodProcessor>("cod");
// Consumer --------------------------------------------------------
public sealed record PayRequest(string Method, decimal Amount);
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private static readonly string[] Supported = { "stripe", "paypal", "cod" };
private readonly IKeyedServiceProvider _sp;
private readonly ILogger<CheckoutController> _log;
public CheckoutController(IKeyedServiceProvider sp, ILogger<CheckoutController> log)
=> (_sp, _log) = (sp, log);
[HttpPost("pay")]
public async Task<IActionResult> Pay([FromBody] PayRequest req, CancellationToken ct)
{
if (!Supported.Contains(req.Method))
return Problem($"Unsupported payment method: {req.Method}",
statusCode: 400, title: "Invalid payment method");
var processor = _sp.GetRequiredKeyedService<IPaymentProcessor>(req.Method);
_log.LogInformation("Charging {Amount} via {Method}", req.Amount, req.Method);
var result = await processor.PayAsync(req.Amount, ct);
return result.Status == "succeeded" ? Ok(result) : Problem(result.Error);
}
}
// Test ------------------------------------------------------------
public sealed class FakeProcessor : IPaymentProcessor
{
public Task<PaymentResult> PayAsync(decimal amount, CancellationToken ct)
=> Task.FromResult(new PaymentResult("succeeded", "test-tx", null));
}
[Fact]
public async Task Pay_returns_ok_for_known_method()
{
var services = new ServiceCollection();
services.AddKeyedScoped<IPaymentProcessor, FakeProcessor>("stripe");
var sp = services.BuildServiceProvider();
var sut = new CheckoutController(sp, NullLogger<CheckoutController>.Instance);
var result = await sut.Pay(new PayRequest("stripe", 9.99m), default);
Assert.IsType<OkObjectResult>(result);
}
Cái không xuất hiện trong ví dụ quan trọng không kém cái có:
- Không có class
PaymentProcessorFactory. Nếu có, nó cũng chỉ ủy thác sang container. - Không có
switchtrong controller. Tập đóng nằm một chỗ (Supported); resolution là một dòng. - Không có
new StripeProcessor(...)ở đâu ngoài test. Cả bản fake cũng chỉ được tạo một lần trong test, bằng test, với data tường minh.
Đây là hình hài Factory Method trong .NET hiện đại. Hình dạng pattern tan vào framework, nhưng câu hỏi pattern trả lời — class concrete nào tôi tạo, cho một key runtime? — chính là điều các đăng ký phía trên trả lời.
Đọc tiếp gì trong series?
- Bài trước: Singleton — khi cần share một instance, không phải tạo một trong nhiều.
- Bài kế: Abstract Factory — khi tập lựa chọn không còn là "payment processor nào?" mà "họ UI control, currency input, shipping form của region nào, biến đổi cùng nhau?".
- Tham chiếu chéo: Strategy — khi caller chọn thuật toán thay vì object để giữ.
- Cây quyết định: Cách chọn design pattern phù hợp.
- Bản đồ series: Giới thiệu.
Một ghi chú về cách đặt tên, vì đây là lần thứ hai chúng ta dùng DI để
thay thế một pattern Gang of Four: framework không "giết" pattern, mà
hiện thực hoá pattern hộ bạn. Pattern là ý tưởng ("ủy thác lựa chọn");
implementation là bất cứ thứ gì ngôn ngữ và framework của bạn làm tự
nhiên. Trong .NET 10, implementation tự nhiên là hai dòng Program.cs.
Nhận ra cái đó là bạn đã thấm pattern.
Câu hỏi thường gặp
Factory Method khác Abstract Factory ở điểm gì?
Create(string method) trả một IPaymentProcessor. Abstract Factory tạo cả họ sản phẩm liên quan biến đổi cùng nhau — factory US trả currency input US, address form US, consent dialog US; factory EU trả phiên bản EU của cả ba. Nếu chỉ có một trục biến đổi, gần như chắc chắn bạn cần Factory Method.Factory Method còn hữu ích khi đã có dependency injection không?
abstract Create() trên class base kiểu giáo trình giờ rất hiếm trong code C# hiện đại. Cái còn lại là ý đồ: caller xin IPaymentProcessor theo một key (string, enum, type) và nhận về implementation đúng mà không new. AddKeyedScoped<T>() và resolver Func<TKey, T> là hai công cụ thường ngày. Pattern còn sống; boilerplate thì không.Khi nào nên dùng Func<TKey, TProduct> thay vì factory class thật?
Func<> khi logic chọn chỉ là một lookup dictionary hoặc một switch trên tập đóng các giá trị. Dùng class factory thật khi cần log lúc chọn, validate key, có rule fallback, hoặc khi factory nhận tham số khác nhau theo từng lần gọi (như merchant ID người dùng truyền vào). Cả hai đều ổn; ranh giới khoảng năm dòng logic chọn.Factory Method khác Strategy pattern thế nào?
var x = Create(key); x.Do() thì bước tạo là factory và lời gọi là strategy. Trong codebase nhỏ bạn có thể gộp cả hai vào một dòng đăng ký DI.