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
- What problem does the Factory Method pattern solve in C#?
- How does the Gang of Four version work?
- How do you replace it with AddKeyedScoped() in .NET 8+?
- When should you keep a hand-rolled factory class?
- How does Factory Method compare to Strategy and Abstract Factory?
- What does a real C# / .NET 10 example look like?
- 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:
- 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
switchin the codebase. - Construction has a non-trivial cost. The
StripeProcessorreads an API key from configuration;PaypalProcessoropens an HTTP client. Spreading those concerns across every caller is a recipe for missed disposals and inconsistent error handling. - 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:
There is no factory class. The container is the factory. The intent of Factory Method — the caller picks by key, the right implementation arrives — is fully preserved. The boilerplate is gone.
Scopedis usually correct here. Each request gets its ownStripeProcessorinstance, which is the right lifetime when the processor holds per-request state (idempotency keys, cancellation tokens, the request-scoped logger). UseAddKeyedSingleton<T>()only if the implementation is genuinely stateless and thread-safe.[FromKeyedServices]lets you pin a key at the parameter level when the choice is static for that endpoint, which is how you would write a Stripe-only webhook:[HttpPost("stripe/webhook")] public async Task<IActionResult> StripeWebhook( [FromKeyedServices("stripe")] IPaymentProcessor stripe, WebhookPayload payload) => Ok(await stripe.HandleWebhookAsync(payload));
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:
- Logging on every resolution. "Tell me which processor was picked
for which order, and why." The container does not give you a hook for
that. A factory class with
_log.LogInformation("Selected {P} for {Order}", processor, orderId)does. - Validation of the key. The user's payment method is one of a known
set and you want to return a
ProblemDetailsresponse with the list of valid options on a bad key — instead of letting the container throw a genericInvalidOperationException. - Per-call construction parameters. The processor needs a merchant
ID derived from the request, not from configuration. DI resolves
types, not instances parameterised by call data. A factory class
with a
Create(string method, string merchantId)signature is cleaner than a thread-local hack. - Fallback rules. "If
paypalis unavailable in this region, returnstripeinstead." Fallback chains belong in a class, not in a registration line.
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:
- No
PaymentProcessorFactoryclass. It would have been pure delegation to the container. - No
switchin the controller. The closed set lives in one place (Supported); resolution is one line. - No
new StripeProcessor(...)anywhere outside the test. Even the fake is constructed once per test, by the test, with explicit data.
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.
Where should you read next in this series?
- Previous: Singleton — when you need to share one instance, not create one of several.
- Next: Abstract Factory — when the set of choices is no longer "which payment processor?" but "which region's family of UI controls, currency input, and shipping form, varying together?".
- Cross-reference: Strategy — when the caller picks an algorithm rather than an object to keep around.
- Decision tree: How to choose the right design pattern.
- Series map: Introduction.
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?
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?
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?
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?
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.