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
- Abstract Factory giải quyết bài toán gì trong C#?
- Bản Gang of Four trông thế nào?
- Cài đặt Abstract Factory với DI hiện đại thế nào?
- Khi nào Abstract Factory bắn trật trong code .NET thật?
- So sánh Abstract Factory với Factory Method và Builder thế nào?
- Một ví dụ thật trong C# / .NET 10 trông thế nào?
- Đọ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ể:
- 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.
- 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.
- Database backend. Test suite dùng
InMemorycầnInMemoryUserRepo,InMemoryOrderRepo,InMemorySessionRepo— và integration suite cần phiên bảnSql*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ó:
- Mỗi sản phẩm test độc lập. Bạn có thể đăng ký một
EuConsentDialoggiả cho một test mà không cần viết lại kit. - Bản thân kit là Singleton khi stateless. Nếu sản phẩm không giữ
state per-request, đổi
AddScoped<ICheckoutUiKit>thànhAddSingleton. Như Singleton đã ghi, factory chính nó là một trong những thứ thường được đăng ký singleton nhất. - Thêm family mới gói gọn một block đăng ký cộng thêm một
casetrong resolver. Không có toán tửnewrắc vào code caller.
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 và feature flag và 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:
- "Family" thực ra không phải family. Kit tích lũy thêm
CreateLogger,CreateClock,CreateHttpClient. Không cái nào biến đổi theo region. Kit thành một dạng service locator nghèo nàn. Đẩy service không liên quan ra và inject thẳng. - Chỉ một trong ba sản phẩm thật sự biến đổi. Nếu
UsConsentDialog == EuConsentDialog, kit đang làm việc của một Factory Method trên mỗi sản phẩm thật sự biến đổi. Tách nó:ICurrencyInputthành cái duy nhất có implementation theo vùng; phần còn lại đăng ký một lần. - Framework đã ép sự biến đổi rồi. Format số, ngày, tiền tệ trong
.NET đến từ
CultureInfo. Nếu bạn với Abstract Factory vì mấy thứ đó, bạn đang phát minh lạiCultureInfo.CurrentCulture. Dùng cơ chế framework, dành factory cho biến đổi application-level mà framework không biết. - Bạn thêm sản phẩm thứ tư, năm, sáu vào kit. Interface kit cứ phình
vì mọi team cần hành vi region-specific đều thêm method. Sau cái thứ
sáu, không ai hiểu kit nữa. Tách thành hai-ba kit nhỏ với đăng ký
chồng —
IShippingUiKit,IPricingUiKit,IConsentUiKit— mỗi cái do một team sở hữu.
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?
- Bài trước: Factory Method — khi một trục biến đổi, không phải nhiều.
- Bài kế: Builder — khi không có biến đổi giữa family, chỉ là một object phức tạp với nhiều field optional.
- Tham chiếu chéo: Singleton — bản thân kit thường đăng ký singleton khi sản phẩm stateless.
- Cross-group: Strategy — khi caller chọn hành vi, không phải họ object.
- 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ề 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ì?
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?
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?
abstract base trong sách GoF thường biến mất hẳn.