Creational Intermediate 11 min read

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
  1. What problem does the Abstract Factory pattern solve in C#?
  2. How does the Gang of Four version look?
  3. How do you implement Abstract Factory with modern DI?
  4. When does Abstract Factory misfire in real .NET code?
  5. How does Abstract Factory compare to Factory Method and Builder?
  6. What does a real C# / .NET 10 example look like?
  7. 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:

  1. 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.
  2. 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.
  3. Database backends. A test suite using InMemory storage needs InMemoryUserRepo, InMemoryOrderRepo, InMemorySessionRepo — and the integration suite needs the Sql* 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:

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 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.

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?
Factory Method picks one concrete class — 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?
When the platform already enforces the variation. 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?
Yes — in modern C# this is the default. Each family becomes a concrete class that implements the kit interface, and the composition root registers exactly one of them per scope (per-tenant, per-region, per-test). Consumers inject the kit interface and never see the families. The abstract base from the GoF book usually disappears entirely.
How do I add a new product to an existing Abstract Factory without breaking callers?
Add the new method to the kit interface and implement it in every family at the same time. This is the cost the pattern asks you to pay: families must stay in sync. If only one family supports the new product, your problem is not Abstract Factory — it is that the products are no longer a coherent family. Split the interface or move the new product out.