Khởi tạo Cơ bản 9 phút đọc

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
  1. Factory Method giải quyết bài toán gì trong C#?
  2. Bản Gang of Four hoạt động ra sao?
  3. Thay nó bằng AddKeyedScoped() trong .NET 8+ thế nào?
  4. Khi nào nên giữ factory class tự cuộn?
  5. So sánh Factory Method với Strategy và Abstract Factory thế nào?
  6. Một ví dụ thật trong C# / .NET 10 trông thế nào?
  7. Đọ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-paybank-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ể:

  1. 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 switch trong codebase.
  2. Khởi tạo có chi phí không tầm thường. StripeProcessor đọc API key từ config; PaypalProcessor mở 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.
  3. 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 để ý:

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ể:

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ó:

Đâ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?

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ì?
Factory Method tạo ra một loại sản phẩm — 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?
Có, nhưng hình hài teo lại. Method 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?
Dùng 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?
Factory Method tạo một object bạn sẽ dùng; Strategy dùng một object đã được tạo sẵn. Trông na ná vì cả hai đều cho caller chọn implementation theo key. Khác nhau ở thời điểm: nếu code caller là 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.