Creational Beginner 9 min read

Factory Method in C#: Replace Switch with Keyed DI

Factory Method pattern in C# / .NET 10: when a switch ladder grows out of control, when keyed DI replaces the GoF version, and when you still need both.

Table of contents
  1. What problem does the Factory Method pattern solve in C#?
  2. How does the Gang of Four version work?
  3. How do you replace it with AddKeyedScoped() in .NET 8+?
  4. When should you keep a hand-rolled factory class?
  5. How does Factory Method compare to Strategy and Abstract Factory?
  6. What does a real C# / .NET 10 example look like?
  7. Where should you read next in this series?

A user picks a payment method on the checkout page — Stripe, PayPal, or Cash on Delivery — and clicks Pay. Six months in, your CheckoutController has a switch statement with three branches. Twelve months in, the switch is in three controllers, with apple-pay and bank-transfer appended at the bottom of two of them but not the third. A bug ticket arrives because COD orders skip a fraud-check call that lives in the fourth branch — only in the web controller; the mobile API still works.

This is the symptom that earns Factory Method its place in the toolbox. The intent is one line: let a separate piece of code decide which concrete class to instantiate, so the caller does not have to. The reason this article needs more than one line is that the modern C# answer to "which concrete class?" almost never looks like the picture in the Gang of Four book. It looks like one or two Program.cs lines and a constructor parameter — and recognising that as Factory Method is the job.

We continue with the e-commerce checkout from the Singleton chapter, but the shared object is no longer the question. The question is: given a payment method string on the request, how do I get the right IPaymentProcessor without a switch?

What problem does the Factory Method pattern solve in C#?

The pattern earns its place when the caller knows it needs an object of some abstraction, but does not know — and must not need to know — which concrete class to instantiate. Three concrete shapes of that:

  1. A closed set of variants behind one interface. Three payment processors today, six next quarter. Adding the seventh must not force you to find every switch in the codebase.
  2. Construction has a non-trivial cost. The StripeProcessor reads an API key from configuration; PaypalProcessor opens an HTTP client. Spreading those concerns across every caller is a recipe for missed disposals and inconsistent error handling.
  3. The choice depends on runtime data. The user's payment method is a string in the request body. Static knowledge of "which class" lives at the wrong level — somewhere there has to be a runtime mapping from key to type.

What is not a Factory Method problem: "I want to share one PriceCatalog across the app". That is the Singleton pattern. Factories create; they do not share.

How does the Gang of Four version work?

The 1994 description splits the world into Creators and Products. Each Creator subclass overrides a Create() method and returns a different Product. Translated to a payment-processor sketch:

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 : asks
    IPaymentProcessorFactory ..> IPaymentProcessor : returns

The literal C# you would write if you were translating the GoF book:

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}")
    };
}

This works, and is occasionally still the right answer — see When should you keep a hand-rolled factory class? below. But notice how much of the factory body is mechanical mapping. The C# language and the .NET DI container have grown features that delete most of it.

How do you replace it with AddKeyedScoped() in .NET 8+?

Since .NET 8, Microsoft.Extensions.DependencyInjection natively supports keyed services. Each registration carries a key — a string, an enum, any object — and the container resolves the correct implementation by key at injection time. The PaymentProcessorFactory class above disappears entirely:

// 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));
    }
}

Three things to notice:

The official Microsoft docs on keyed services are the authoritative reference; the API surface is small and worth a five-minute read.

For projects still on .NET 6 or 7, the equivalent recipe is a Func<TKey, TProduct> registered in 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}")
});

The caller injects Func<string, IPaymentProcessor> and calls it like a method. This is the pre-.NET-8 idiom that has been in production .NET codebases for a decade. It is also still the right tool when you want the resolver itself to log, validate, or fall back — see next.

When should you keep a hand-rolled factory class?

A real PaymentProcessorFactory class is still warranted when the selection logic outgrows what an AddKeyedScoped line can express. Four specific triggers:

When in doubt, start with AddKeyedScoped. Promote to a factory class the day the registration line grows a comment.

How does Factory Method compare to Strategy and Abstract Factory?

These three patterns confuse interviewers and PR reviewers in equal measure. The differences fit on one card:

Aspect Factory Method Strategy Abstract Factory
Returns An object you will use (Used directly, not returned) A family of related objects
Decision is about Which concrete class to instantiate Which algorithm to run on shared input Which family to build
Number of axes of variation One One Many, varying together
Typical caller code var p = factory.Create(key); p.Pay() strategy.Apply(state) var ui = factory.Build(); ui.AddressForm; ui.ConsentDialog
Typical .NET implementation AddKeyedScoped<T>() A registered Func<> or IStrategy One IFactory per family scope

The cleanest rule: if the caller will use the returned object exactly once and then discard it, you probably want Strategy injected directly, not Factory Method. If the caller hangs on to the object across method calls (the IPaymentProcessor is used for the whole checkout flow, not just one charge), Factory Method is the right framing.

What does a real C# / .NET 10 example look like?

A complete example you could check into a real codebase. We assume .NET 8 or later for 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 to 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);
}

What you do not see in this example is as important as what you do:

This is what the Factory Method intent looks like in modern .NET. The shape of the pattern has dissolved into the framework, but the question the pattern answers — which concrete class do I create, given a runtime key? — is exactly what the registrations above answer.

A note on naming, since this is the second time we have used DI to replace a Gang of Four pattern: this is not the framework "killing" the pattern. It is the framework implementing it for you. The pattern is the idea ("delegate the choice"); the implementation is whatever your language and framework make natural. In .NET 10, the natural implementation is two Program.cs lines. Recognise that and you have absorbed the pattern.

Frequently asked questions

What is the difference between Factory Method and Abstract Factory?
Factory Method creates one product type — Create(string method) returns an IPaymentProcessor. Abstract Factory creates a family of related products that vary together — one US factory returns US currency input, US address form, and US consent dialog; the EU factory returns the EU versions of all three. If you only have one axis of variation, you almost certainly want Factory Method.
Is the Factory Method pattern still useful with dependency injection?
Yes, but its shape collapses. The classical abstract Create() method on a base class is rare in modern C# code. What survives is the intent: the caller asks for IPaymentProcessor by some key (string, enum, type) and receives the right implementation without new. AddKeyedScoped<T>() and Func<TKey, T> resolvers are the two everyday tools. The pattern lives on; the boilerplate doesn't.
When should I use a Func<TKey, TProduct> instead of a real factory class?
Use the Func<> when the resolution logic is a single dictionary lookup or a switch over a closed set of values. Use a real factory class when you need logging on selection, validation of the key, fallback rules, or when the factory itself takes parameters that vary per call (like a user-provided merchant ID). Both approaches are valid; the line is roughly five lines of resolution logic.
How does Factory Method differ from the Strategy pattern?
Factory Method creates an object you will use; Strategy uses an object that has already been created. They look similar because both let the caller pick an implementation by key. The split is about timing: if the caller's code is var x = Create(key); x.Do(), the creation step is the factory and the call is the strategy. In small codebases you can collapse both into one DI registration.