Khởi tạo Trung bình 11 phút đọc

Abstract Factory trong C#: Khi Sản Phẩm Biến Đổi Cùng Nhau

Abstract Factory pattern trong C# / .NET 10: tạo một họ sản phẩm liên quan biến đổi cùng nhau (UI kit US vs EU) và cách DI thay thế factory tay.

Mục lục
  1. Abstract Factory giải quyết bài toán gì trong C#?
  2. Bản Gang of Four trông thế nào?
  3. Cài đặt Abstract Factory với DI hiện đại thế nào?
  4. Khi nào Abstract Factory bắn trật trong code .NET thật?
  5. So sánh Abstract Factory với Factory Method và Builder 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?

Shop của bạn chỉ có ở Mỹ suốt hai năm. Kế hoạch mở rộng đáp xuống bàn, bạn có tám tuần để ship sang EU. Đợt bug đầu tiên đoán được nhưng mệt: form shipping US có ô ZIP năm chữ số mà khách EU không điền được; dialog GDPR consent EU bật lên cho khách US làm họ bối rối; một merge xui mix currency input US ("\(") với country picker EU, làm khách Pháp thấy "\)" đứng cạnh địa chỉ Paris. Không bug nào riêng lẻ phức tạp. Tất cả chỉ là cùng một bug: các phần UI checkout vốn phải đi cùng nhau lại không đi cùng nhau.

Đây là triệu chứng kéo Abstract Factory vào hộp đồ nghề. Ý đồ một dòng: làm cho một họ object liên quan luôn được tạo cùng nhau, để mix sai trở thành bất khả. Như Factory Method, hình hài .NET hiện đại của pattern này là vài dòng DI thay vì class abstract base trong sách giáo khoa. Câu hỏi thú vị là nhận ra khi nào pattern áp dụng — và khi nào không.

Vẫn là checkout e-commerce, nhưng câu hỏi đổi. Singleton hỏi làm sao share một object?; Factory Method hỏi trong ba class concrete khởi tạo cái nào?. Bài này hỏi: cả một họ UI control nào được khởi tạo, biến đổi cùng nhau?

Abstract Factory giải quyết bài toán gì trong C#?

Pattern xứng chỗ khi nhiều sản phẩm phải biến đổi cùng nhau theo cùng một trục, và mix sai sẽ là bug. Ba hình dạng cụ thể:

  1. UI kit theo region. Checkout US vs EU vs APAC. Currency input, address form, consent dialog, shipping option — cả bốn phải khớp region. Mix chúng tạo UI hỏng hiện ra trước mặt user thật.
  2. Bundle theme. Dark mode vs light mode. Màu button, nền panel, focus ring, scroll bar. Mix button đen với panel sáng là bug theme-leak kinh điển.
  3. Database backend. Test suite dùng InMemory cần InMemoryUserRepo, InMemoryOrderRepo, InMemorySessionRepo — và integration suite cần phiên bản Sql* của cả ba. Cấu hình mix nửa vời lặng lẽ pass một số test trong khi rò rỉ DB connection thật ở các test khác.

Cái không phải bài toán Abstract Factory: "Tôi có một interface và ba implementation phải chọn lúc runtime". Đó là Factory Method. Abstract Factory đặc biệt nói về nhiều interface, chọn như một bộ ăn khớp.

Một test thử nhanh: viết các sản phẩm ra giấy. Vạch một đường ngang giữa chúng. Nếu đổi family của một sản phẩm mà không đổi mấy cái còn lại được, bạn không có family — bạn có các lựa chọn không liên quan, và pattern chỉ tổ thêm nghi lễ. Nếu các sản phẩm bắt buộc phải dịch chuyển cùng nhau, Abstract Factory là khung đúng.

Bản Gang of Four trông thế nào?

Mô tả 1994 cho bạn một class abstract factory với một method Create* cho mỗi sản phẩm, và một subclass concrete factory cho mỗi family. Dịch sang UI checkout:

classDiagram
    class ICheckoutUiKit {
        <<interface>>
        +CreateAddressForm() IAddressForm
        +CreateConsentDialog() IConsentDialog
        +CreateCurrencyInput() ICurrencyInput
    }
    class UsCheckoutUiKit
    class EuCheckoutUiKit
    ICheckoutUiKit <|.. UsCheckoutUiKit
    ICheckoutUiKit <|.. EuCheckoutUiKit

    class IAddressForm
    class IConsentDialog
    class ICurrencyInput
    class UsAddressForm
    class EuAddressForm
    class UsConsentDialog
    class EuConsentDialog
    class UsCurrencyInput
    class EuCurrencyInput

    IAddressForm <|.. UsAddressForm
    IAddressForm <|.. EuAddressForm
    IConsentDialog <|.. UsConsentDialog
    IConsentDialog <|.. EuConsentDialog
    ICurrencyInput <|.. UsCurrencyInput
    ICurrencyInput <|.. EuCurrencyInput

    UsCheckoutUiKit ..> UsAddressForm : tạo
    UsCheckoutUiKit ..> UsConsentDialog : tạo
    UsCheckoutUiKit ..> UsCurrencyInput : tạo
    EuCheckoutUiKit ..> EuAddressForm : tạo
    EuCheckoutUiKit ..> EuConsentDialog : tạo
    EuCheckoutUiKit ..> EuCurrencyInput : tạo

Đoạn C# nguyên si nếu dịch sách giáo khoa:

public interface IAddressForm   { string Render(); }
public interface IConsentDialog { string Render(); }
public interface ICurrencyInput { string Render(); }

public sealed class UsAddressForm   : IAddressForm   { /* ZIP 5 chữ số */ }
public sealed class EuAddressForm   : IAddressForm   { /* postcode + country */ }
public sealed class UsConsentDialog : IConsentDialog { /* opt-out ngắn */ }
public sealed class EuConsentDialog : IConsentDialog { /* GDPR opt-in đầy đủ */ }
public sealed class UsCurrencyInput : ICurrencyInput { /* USD prefix */ }
public sealed class EuCurrencyInput : ICurrencyInput { /* EUR suffix + dấu phẩy thập phân */ }

public interface ICheckoutUiKit
{
    IAddressForm   CreateAddressForm();
    IConsentDialog CreateConsentDialog();
    ICurrencyInput CreateCurrencyInput();
}

public sealed class UsCheckoutUiKit : ICheckoutUiKit
{
    public IAddressForm   CreateAddressForm()   => new UsAddressForm();
    public IConsentDialog CreateConsentDialog() => new UsConsentDialog();
    public ICurrencyInput CreateCurrencyInput() => new UsCurrencyInput();
}

public sealed class EuCheckoutUiKit : ICheckoutUiKit
{
    public IAddressForm   CreateAddressForm()   => new EuAddressForm();
    public IConsentDialog CreateConsentDialog() => new EuConsentDialog();
    public ICurrencyInput CreateCurrencyInput() => new EuCurrencyInput();
}

Cái này chạy được. Cũng là chỗ 90% bài viết về Abstract Factory dừng lại — và là chỗ code production .NET hiện đại bắt đầu.

Cài đặt Abstract Factory với DI hiện đại thế nào?

Cấu trúc giáo khoa đúng; cái thay đổi là ai sở hữu việc tạo instance. Trong codebase .NET 10, class kit vẫn còn — nó hữu ích thật vì là một object đơn để inject và xin các phần. Cái bạn gần như không bao giờ viết nữa là các lời gọi new bên trong nó. DI container build các phần; kit chỉ bundle lại.

public sealed class UsCheckoutUiKit : ICheckoutUiKit
{
    public UsCheckoutUiKit(
        UsAddressForm   address,
        UsConsentDialog consent,
        UsCurrencyInput currency)
    {
        AddressForm   = address;
        ConsentDialog = consent;
        CurrencyInput = currency;
    }
    public IAddressForm   AddressForm   { get; }
    public IConsentDialog ConsentDialog { get; }
    public ICurrencyInput CurrencyInput { get; }
}

(Method Create* thành property, vì mỗi phần là cùng một instance cho cả request và không có lý do gọi Create hai lần.)

Composition root sau đó chọn kit nào để đăng ký dựa trên region:

// Program.cs ----------------------------------------------------
builder.Services.AddScoped<UsAddressForm>();
builder.Services.AddScoped<UsConsentDialog>();
builder.Services.AddScoped<UsCurrencyInput>();
builder.Services.AddScoped<UsCheckoutUiKit>();

builder.Services.AddScoped<EuAddressForm>();
builder.Services.AddScoped<EuConsentDialog>();
builder.Services.AddScoped<EuCurrencyInput>();
builder.Services.AddScoped<EuCheckoutUiKit>();

// Lựa chọn family thật sự — đúng một ICheckoutUiKit cho mỗi scope.
builder.Services.AddScoped<ICheckoutUiKit>(sp =>
{
    var region = sp.GetRequiredService<IRegionResolver>().CurrentRegion;
    return region switch
    {
        Region.EU => sp.GetRequiredService<EuCheckoutUiKit>(),
        _         => sp.GetRequiredService<UsCheckoutUiKit>(),
    };
});

Ba thứ cách này cho mà bản GoF không có:

Cho .NET 8+, bạn cũng có thể diễn đạt lựa chọn bằng keyed services nếu key là enum/string đơn giản và resolution chỉ là lookup:

builder.Services.AddKeyedScoped<ICheckoutUiKit, UsCheckoutUiKit>(Region.US);
builder.Services.AddKeyedScoped<ICheckoutUiKit, EuCheckoutUiKit>(Region.EU);
// caller resolve: sp.GetRequiredKeyedService<ICheckoutUiKit>(currentRegion);

Cách keyed-service sạch hơn khi bạn có nhiều region và không có rule resolution custom. Custom resolver sạch hơn khi lựa chọn phụ thuộc nhiều hơn một input (region feature flag test override).

Khi nào Abstract Factory bắn trật trong code .NET thật?

Pattern trông gọn trên giấy và hay mọc xấu trong thực tế. Bốn cái bẫy:

Cái bẫy sâu hơn đằng sau cả bốn: Abstract Factory nói "mấy thứ này đi cùng nhau". Ngày chúng thôi đi cùng nhau, pattern thành bức tường chịu lực không ai dám đụng.

So sánh Abstract Factory với Factory Method và Builder thế nào?

Ba creational pattern đều dính líu "tạo đồ" hay đứng cạnh nhau trong phỏng vấn. Khác biệt rõ một khi đã có domain checkout neo lại:

Khía cạnh Abstract Factory Factory Method Builder
Trả về Một họ sản phẩm Một sản phẩm Một object phức tạp
Số trục biến đổi Nhiều, biến đổi cùng nhau Một Không (chỉ field optional)
Code caller kit.AddressForm; kit.ConsentDialog var p = factory.Create(key) new Builder().With...().Build()
Thêm biến thể mới Thêm class cho mỗi sản phẩm trong family Thêm một class cộng đăng ký (Cùng builder, thêm With*)
Hình hài .NET điển hình Một class kit concrete cho mỗi family + scoped DI AddKeyedScoped<T>() Fluent builder + record với with

Quyết định khó nhất là giữa Abstract Factory và Factory Method. Quy tắc thực tế: nếu xoá một sản phẩm khỏi kit cũng xoá luôn cả một family, bạn có Abstract Factory; nếu xoá một sản phẩm mà không đụng phần còn lại, bạn có Factory Method trên một câu hỏi khác và kit là thừa.

Một ví dụ thật trong C# / .NET 10 trông thế nào?

Hình dạng đầy đủ, chạy được — phần sẽ sống vào codebase thật. Kit là Scoped vì consent dialog đọc culture per-request; currency input thật sự per-region nhưng ngoài ra stateless.

// Domain ----------------------------------------------------------
public enum Region { US, EU }

public interface IRegionResolver { Region CurrentRegion { get; } }

public sealed class HeaderRegionResolver : IRegionResolver
{
    public HeaderRegionResolver(IHttpContextAccessor http)
        => CurrentRegion = http.HttpContext?.Request.Headers["X-Region"] == "EU"
            ? Region.EU : Region.US;
    public Region CurrentRegion { get; }
}

// Products --------------------------------------------------------
public interface IAddressForm   { string Render(); }
public interface IConsentDialog { string Render(); }
public interface ICurrencyInput { string Render(decimal amount); }

public sealed class UsAddressForm   : IAddressForm   { public string Render() => "<input name='zip' maxlength='5' />"; }
public sealed class EuAddressForm   : IAddressForm   { public string Render() => "<input name='postcode' /><select name='country'>...</select>"; }
public sealed class UsConsentDialog : IConsentDialog { public string Render() => "<p>By continuing you agree to our terms.</p>"; }
public sealed class EuConsentDialog : IConsentDialog { public string Render() => "<form><label><input type='checkbox' required>I consent (GDPR)</label></form>"; }
public sealed class UsCurrencyInput : ICurrencyInput { public string Render(decimal a) => $"$ {a:0.00}"; }
public sealed class EuCurrencyInput : ICurrencyInput { public string Render(decimal a) => $"{a:0,00} €"; }

// Kit -------------------------------------------------------------
public interface ICheckoutUiKit
{
    IAddressForm   AddressForm   { get; }
    IConsentDialog ConsentDialog { get; }
    ICurrencyInput CurrencyInput { get; }
}

public sealed class UsCheckoutUiKit : ICheckoutUiKit
{
    public UsCheckoutUiKit(UsAddressForm a, UsConsentDialog c, UsCurrencyInput ci)
        => (AddressForm, ConsentDialog, CurrencyInput) = (a, c, ci);
    public IAddressForm   AddressForm   { get; }
    public IConsentDialog ConsentDialog { get; }
    public ICurrencyInput CurrencyInput { get; }
}

public sealed class EuCheckoutUiKit : ICheckoutUiKit
{
    public EuCheckoutUiKit(EuAddressForm a, EuConsentDialog c, EuCurrencyInput ci)
        => (AddressForm, ConsentDialog, CurrencyInput) = (a, c, ci);
    public IAddressForm   AddressForm   { get; }
    public IConsentDialog ConsentDialog { get; }
    public ICurrencyInput CurrencyInput { get; }
}

// Composition root ------------------------------------------------
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IRegionResolver, HeaderRegionResolver>();

builder.Services.AddScoped<UsAddressForm>();
builder.Services.AddScoped<UsConsentDialog>();
builder.Services.AddScoped<UsCurrencyInput>();
builder.Services.AddScoped<UsCheckoutUiKit>();

builder.Services.AddScoped<EuAddressForm>();
builder.Services.AddScoped<EuConsentDialog>();
builder.Services.AddScoped<EuCurrencyInput>();
builder.Services.AddScoped<EuCheckoutUiKit>();

builder.Services.AddScoped<ICheckoutUiKit>(sp => sp.GetRequiredService<IRegionResolver>().CurrentRegion switch
{
    Region.EU => sp.GetRequiredService<EuCheckoutUiKit>(),
    _         => sp.GetRequiredService<UsCheckoutUiKit>(),
});

// Consumer --------------------------------------------------------
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
    private readonly ICheckoutUiKit _kit;
    public CheckoutController(ICheckoutUiKit kit) => _kit = kit;

    [HttpGet("ui")]
    public IActionResult RenderUi(decimal amount = 0m) => Ok(new
    {
        Address  = _kit.AddressForm.Render(),
        Consent  = _kit.ConsentDialog.Render(),
        Currency = _kit.CurrencyInput.Render(amount),
    });
}

// Test ------------------------------------------------------------
public sealed class FakeKit : ICheckoutUiKit
{
    public IAddressForm   AddressForm   { get; init; } = default!;
    public IConsentDialog ConsentDialog { get; init; } = default!;
    public ICurrencyInput CurrencyInput { get; init; } = default!;
}

[Fact]
public void Renders_eu_components_when_kit_is_eu()
{
    var kit = new FakeKit
    {
        AddressForm   = new EuAddressForm(),
        ConsentDialog = new EuConsentDialog(),
        CurrencyInput = new EuCurrencyInput(),
    };
    var sut = new CheckoutController(kit);
    var ok = (OkObjectResult)sut.RenderUi(9.99m);
    Assert.Contains("postcode", ok.Value!.ToString());
    Assert.Contains("GDPR",     ok.Value!.ToString());
    Assert.Contains("€",        ok.Value!.ToString());
}

Hình dạng trên trang chính là toàn bộ pattern. CheckoutController không bao giờ nhắc tới UsCheckoutUiKit hay EuCheckoutUiKit; nó không bao giờ biết đang chạy cho region nào. Ngày region thứ ba đáp xuống — ví dụ APAC — diff là một giá trị enum, sáu implementation sản phẩm, một class kit, và một case mới trong resolver. Controller không đổi.

Đọc tiếp gì trong series?

Một ghi chú về kinh tế của pattern: Abstract Factory có chi phí cam kết cao nhất trong nhóm creational. Mỗi sản phẩm mới bạn thêm phải implement ở mọi family. Nếu các family trôi xa nhau theo thời gian — mà thực tế hay xảy ra — pattern từ chỗ ngăn bug chuyển sang tạo implementation rỗng. Để mắt đến ngày một family có nửa số sản phẩm là throw new NotSupportedException(). Đó là ngày tách kit, không phải ngày thêm method.

Câu hỏi thường gặp

Abstract Factory khác Factory Method ở điểm gì?
Factory Method chọn một class concrete — Create(key) trả một IPaymentProcessor. Abstract Factory chọn một họ class concrete biến đổi cùng nhau — chọn EuCheckoutUiKit trả về address form EU, currency input EU, consent dialog EU như một bộ ăn khớp. Nếu chỉ một trục biến đổi, dùng Factory Method. Nếu ba thứ phải đổi cùng nhịp, dùng Abstract Factory.
Khi nào Abstract Factory thừa thãi trong .NET hiện đại?
Khi platform đã ép sự biến đổi đó. CultureInfo đã cho format số, ngày, tiền tệ theo region; bạn không cần Abstract Factory bọc lại. Pattern xứng đáng khi application — không phải framework — sở hữu họ biến thể, và nếu mix sai sẽ tạo ra UI hoặc workflow hỏng nhìn thấy được.
Có cài đặt Abstract Factory chỉ bằng dependency injection không?
Có — trong C# hiện đại đó là cách mặc định. Mỗi family thành một class concrete implement interface kit, và composition root đăng ký đúng một family cho mỗi scope (per-tenant, per-region, per-test). Consumer inject interface kit và không bao giờ thấy các family. Class abstract base trong sách GoF thường biến mất hẳn.
Làm sao thêm sản phẩm mới vào Abstract Factory mà không phá caller?
Thêm method mới vào interface kit và implement nó ở mọi family cùng lúc. Đó là cái giá pattern bắt bạn trả: các family phải đồng bộ. Nếu chỉ một family hỗ trợ sản phẩm mới, vấn đề không phải Abstract Factory — vấn đề là các sản phẩm không còn thuộc một họ ăn khớp. Tách interface hoặc đẩy sản phẩm mới ra ngoài.