Abstract Factory in C#: When Products Vary Together
Abstract Factory pattern in C# / .NET 10: produce a family of related products that vary together (US vs EU UI kits) and how DI replaces the manual factory.
Table of contents
- What problem does the Abstract Factory pattern solve in C#?
- How does the Gang of Four version look?
- How do you implement Abstract Factory with modern DI?
- When does Abstract Factory misfire in real .NET code?
- How does Abstract Factory compare to Factory Method and Builder?
- What does a real C# / .NET 10 example look like?
- Where should you read next in this series?
Your shop has been US-only for two years. The expansion plan lands and you have eight weeks to ship to the EU. The first wave of bugs is predictable but exhausting: the US shipping form has a five-digit ZIP field that EU customers can't fill in; the EU GDPR consent dialog appears for US customers and confuses them; one bad merge mixes a US currency input ("$") with an EU country picker, so a French buyer sees "$" against a Paris address. None of these bugs are individually complicated. They are all the same bug: the parts of the checkout UI that should travel together aren't.
This is the symptom that makes Abstract Factory earn its place. The intent is one line: make a family of related objects always created together, so a wrong cross-mix becomes impossible. Like Factory Method, the pattern's modern .NET shape is a few DI lines rather than the textbook abstract base class. The interesting question is recognising when the pattern applies at all — and when it does not.
We continue with the same e-commerce checkout, but the question changes. Singleton asked how do we share one object?; Factory Method asked which one of three concrete classes do we instantiate? This article asks: which whole family of UI controls do we instantiate, varying together?
What problem does the Abstract Factory pattern solve in C#?
The pattern earns its place when several products must vary together along the same axis, and a wrong cross-mix would be a bug. Three concrete shapes:
- Region-specific UI kits. US vs EU vs APAC checkout. Currency input, address form, consent dialog, shipping options — all four must agree on the region. Mixing them produces an invalid UI shown to a real user.
- Theme bundles. Dark mode vs light mode. Button colours, panel backgrounds, focus rings, scroll bars. Mixing dark buttons with a light panel is the classic theme-leak bug.
- Database backends. A test suite using
InMemorystorage needsInMemoryUserRepo,InMemoryOrderRepo,InMemorySessionRepo— and the integration suite needs theSql*versions of all three. A half-mixed configuration silently passes some tests while leaking real DB connections in others.
What is not an Abstract Factory problem: "I have one interface and three implementations to choose from at runtime". That is Factory Method. Abstract Factory is specifically about several interfaces, picked as a coherent set.
A useful litmus test: write down the products on paper. Draw a horizontal line between them. If you can change one product's family without changing the others, you do not have a family — you have unrelated choices, and the pattern will only add ceremony. If the products must move together, Abstract Factory is the right framing.
How does the Gang of Four version look?
The 1994 description gives you an abstract factory class with one
Create* method per product, and one concrete factory subclass per
family. Translated to the checkout UI:
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 : creates
UsCheckoutUiKit ..> UsConsentDialog : creates
UsCheckoutUiKit ..> UsCurrencyInput : creates
EuCheckoutUiKit ..> EuAddressForm : creates
EuCheckoutUiKit ..> EuConsentDialog : creates
EuCheckoutUiKit ..> EuCurrencyInput : creates
The literal C# you would write if translating the textbook:
public interface IAddressForm { string Render(); }
public interface IConsentDialog { string Render(); }
public interface ICurrencyInput { string Render(); }
public sealed class UsAddressForm : IAddressForm { /* 5-digit ZIP */ }
public sealed class EuAddressForm : IAddressForm { /* postcode + country */ }
public sealed class UsConsentDialog : IConsentDialog { /* short opt-out */ }
public sealed class EuConsentDialog : IConsentDialog { /* full GDPR opt-in */ }
public sealed class UsCurrencyInput : ICurrencyInput { /* USD prefix */ }
public sealed class EuCurrencyInput : ICurrencyInput { /* EUR suffix + comma decimal */ }
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();
}
This works. It is also where 90% of articles about Abstract Factory stop — and it is where the actual production code in modern .NET starts.
How do you implement Abstract Factory with modern DI?
The textbook structure is correct; what changes is who owns instance
creation. In a .NET 10 codebase, the kit class still exists — it is
genuinely useful as a single object you can inject and ask for parts.
What you almost never write any more is the new calls inside it. The
DI container builds the parts; the kit just bundles them.
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; }
}
(The Create* methods became properties, since each part is the same
instance for the whole request and there is no reason to call Create
twice.)
The composition root then picks which kit to register based on the 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>();
// The actual choice of family — exactly one ICheckoutUiKit per scope.
builder.Services.AddScoped<ICheckoutUiKit>(sp =>
{
var region = sp.GetRequiredService<IRegionResolver>().CurrentRegion;
return region switch
{
Region.EU => sp.GetRequiredService<EuCheckoutUiKit>(),
_ => sp.GetRequiredService<UsCheckoutUiKit>(),
};
});
Three things this gives you that the GoF version did not:
- Each product is independently testable. You can register a fake
EuConsentDialogfor one test without rewriting the kit. - The kit is itself a Singleton when stateless. If your products
don't carry per-request state, change
AddScoped<ICheckoutUiKit>toAddSingleton. As Singleton noted, the factory itself is one of the most common things to register as a singleton. - Adding a new family is one block of registrations plus one
casein the resolver. Nonewoperators get sprinkled into the caller's code.
For .NET 8+, you can also express the choice with keyed services if the key is a flat enum or string and the resolution is purely lookup:
builder.Services.AddKeyedScoped<ICheckoutUiKit, UsCheckoutUiKit>(Region.US);
builder.Services.AddKeyedScoped<ICheckoutUiKit, EuCheckoutUiKit>(Region.EU);
// caller resolves: sp.GetRequiredKeyedService<ICheckoutUiKit>(currentRegion);
The keyed-service approach is cleaner when you have many regions and no custom resolution rules. The custom resolver is cleaner when the choice depends on more than one input (region and feature flag and test override).
When does Abstract Factory misfire in real .NET code?
The pattern looks tidy on paper and frequently grows badly in practice. Four traps to know about:
- The "family" is not actually a family. The kit accumulates a
CreateLogger, aCreateClock, aCreateHttpClient. None of these vary by region. The kit has become a poor man's service locator. Move the unrelated services out and inject them directly. - Only one of the three products actually varies. If
UsConsentDialog == EuConsentDialog, the kit is doing the work of a Factory Method on the one product that does vary. Split it:ICurrencyInputbecomes the only thing with regional implementations; the rest get registered once. - The framework already enforces the variation. Number, date, and
currency formatting in .NET come from
CultureInfo. If you reach for Abstract Factory because of them, you are reinventingCultureInfo.CurrentCulture. Use the framework's mechanism and keep your factory for application-level variation that the framework knows nothing about. - You added a fourth, fifth, sixth product to the kit. The kit
interface keeps growing because every team that needs region-specific
behaviour adds a method. After the sixth, no one understands the kit
any more. Split it into two or three smaller kits with overlapping
registrations —
IShippingUiKit,IPricingUiKit,IConsentUiKit— each owned by a single team.
The deeper trap behind all four: Abstract Factory says "these things travel together". The day they stop travelling together, the pattern becomes a load-bearing wall in your codebase that no one wants to touch.
How does Abstract Factory compare to Factory Method and Builder?
The three creational patterns that involve "create stuff" land side-by-side in interviews. The split is sharp once you have a checkout domain to anchor it:
| Aspect | Abstract Factory | Factory Method | Builder |
|---|---|---|---|
| Returns | A family of products | One product | One complex object |
| Number of axes of variation | Many, varying together | One | Zero (just optional fields) |
| Caller code | kit.AddressForm; kit.ConsentDialog |
var p = factory.Create(key) |
new Builder().With...().Build() |
| Adding a new variant means | Adding a class for each product per family | Adding one class plus a registration | (Same builder, more With* calls) |
| Typical .NET shape | One concrete kit class per family + scoped DI | AddKeyedScoped<T>() |
Fluent builder + record with with |
The single hardest call is between Abstract Factory and Factory Method. A practical rule: if removing one product from the kit would also delete one whole family, you have an Abstract Factory; if you can delete one product without disturbing the rest, you have a Factory Method on a different question and the kit was unnecessary.
What does a real C# / .NET 10 example look like?
A complete, runnable shape — the parts that would survive into a real
codebase. The kit is Scoped because the consent dialog reads
per-request culture; the currency input is genuinely per-region but
otherwise 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());
}
The shape on the page is the entire pattern. CheckoutController never
mentions UsCheckoutUiKit or EuCheckoutUiKit; it never knows which
region it runs for. The day a third region lands — say APAC — the
diff is one enum value, six product implementations, one kit class,
and one new case in the resolver. The controller does not change.
Where should you read next in this series?
- Previous: Factory Method — when one axis varies, not many.
- Next: Builder — when there is no variation across families, just a single complex object with many optional fields.
- Cross-reference: Singleton — the kit itself is often registered as a singleton when its products are stateless.
- Cross-group: Strategy — when the caller picks behaviour, not a family of objects.
- Decision tree: How to choose the right design pattern.
- Series map: Introduction.
A pattern-economy note: Abstract Factory has the highest commitment
cost of the creational patterns. Every new product you add must be
implemented in every family. If your families drift apart over time —
which they often do — the pattern goes from saving you bugs to creating
empty implementations. Watch for the day a family has half its products
stubbed out as throw new NotSupportedException(). That is the day to
split the kit, not to keep adding methods.
Frequently asked questions
What is the difference between Abstract Factory and Factory Method?
Create(key) returns an IPaymentProcessor. Abstract Factory picks a family of concrete classes that must vary together — picking EuCheckoutUiKit returns an EU address form, EU currency input, and EU consent dialog as a coherent set. If only one axis varies, you want Factory Method. If three things must change in lockstep, you want Abstract Factory.When does Abstract Factory feel like overkill in modern .NET?
CultureInfo already gives you region-specific number, date, and currency formatting; you don't need an Abstract Factory to wrap that. The pattern earns its keep when the application — not the framework — owns the family of variants, and a wrong cross-mix would produce a visibly broken UI or workflow.Can I implement Abstract Factory using just dependency injection?
abstract base from the GoF book usually disappears entirely.