Beyond If/Else: Clean Strategy Pattern Implementation with Keyed Services

Clean Strategy Pattern in .NET 8 with Keyed Services

Use .NET 8 keyed services to replace if/else blocks and cleanly implement the Strategy pattern for payment gateways.

.NET Development·By amarozka · November 24, 2025

Clean Strategy Pattern in .NET 8 with Keyed Services

Are you still wiring your strategies with a giant if/else or switch that nobody wants to touch? The kind where adding a new payment method means editing the same 200‑line method again? Let’s fix that once and for all.

In one of my projects we had exactly this: a nice Strategy pattern on paper, but in practice it was hidden behind a fat factory method full of switch branches. Adding a new payment gateway was “easy”… as long as you remembered to update three places and didn’t break existing flows.

With modern .NET DI and keyed services, you can keep the Strategy pattern clean: no custom factory, no switch, no reflection tricks. You register strategies by key and let the container pick the right implementation at runtime.

The usual mess: Strategy pattern with a fat switch

Let’s start with the classic setup.

You have a payment interface:

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default);
}

public sealed class PaymentRequest
{
    public required decimal Amount { get; init; }
    public required string Currency { get; init; }
    public required string CustomerId { get; init; }
    public required PaymentProviderKey Provider { get; init; }
}

public sealed class PaymentResult
{
    public required bool Success { get; init; }
    public string? TransactionId { get; init; }
    public string? Error { get; init; }
}

public enum PaymentProviderKey
{
    Stripe,
    Paypal,
    Sandbox
}

And several implementations:

public sealed class StripePaymentGateway : IPaymentGateway
{
    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        // Stripe API call here
        return Task.FromResult(new PaymentResult
        {
            Success = true,
            TransactionId = Guid.NewGuid().ToString()
        });
    }
}

public sealed class PaypalPaymentGateway : IPaymentGateway
{
    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        // PayPal API call here
        return Task.FromResult(new PaymentResult
        {
            Success = true,
            TransactionId = Guid.NewGuid().ToString()
        });
    }
}

public sealed class SandboxPaymentGateway : IPaymentGateway
{
    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        // Fake gateway for tests / staging
        return Task.FromResult(new PaymentResult
        {
            Success = true,
            TransactionId = $"sandbox-{Guid.NewGuid()}"
        });
    }
}

So far so good.

Now you need a PaymentService that chooses the right strategy based on the Provider key. The naive version:

public sealed class PaymentService
{
    private readonly StripePaymentGateway _stripe;
    private readonly PaypalPaymentGateway _paypal;
    private readonly SandboxPaymentGateway _sandbox;

    public PaymentService(
        StripePaymentGateway stripe,
        PaypalPaymentGateway paypal,
        SandboxPaymentGateway sandbox)
    {
        _stripe = stripe;
        _paypal = paypal;
        _sandbox = sandbox;
    }

    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        return request.Provider switch
        {
            PaymentProviderKey.Stripe  => _stripe.ChargeAsync(request, ct),
            PaymentProviderKey.Paypal  => _paypal.ChargeAsync(request, ct),
            PaymentProviderKey.Sandbox => _sandbox.ChargeAsync(request, ct),
            _ => throw new ArgumentOutOfRangeException(nameof(request.Provider), request.Provider, "Unknown provider")
        };
    }
}

Problems with this approach:

  • PaymentService now knows about every concrete gateway.
  • Adding a new provider means editing the switch and the constructor.
  • You can’t plug in a custom implementation (for a specific tenant) without touching this class.

This is exactly the kind of thing that grows from 10 to 100+ lines over time.

We want PaymentService to depend only on abstractions and let the container resolve the right strategy.

Keyed services in .NET: the missing piece

In .NET 8 (and ASP.NET Core 8) DI gained keyed services.

Idea in one line:

You can register multiple implementations of the same interface and associate each one with a key.

Later you can ask the container: “Give me IPaymentGateway with key Stripe.

Registration example:

builder.Services.AddKeyedTransient<IPaymentGateway, StripePaymentGateway>(PaymentProviderKey.Stripe);
builder.Services.AddKeyedTransient<IPaymentGateway, PaypalPaymentGateway>(PaymentProviderKey.Paypal);
builder.Services.AddKeyedTransient<IPaymentGateway, SandboxPaymentGateway>(PaymentProviderKey.Sandbox);

At runtime you resolve by key:

var gateway = serviceProvider
    .GetRequiredKeyedService<IPaymentGateway>(PaymentProviderKey.Stripe);

This fits the Strategy pattern very nicely:

  • The interface is the strategy contract (IPaymentGateway).
  • Each implementation is a strategy.
  • The key chooses which strategy to use.
  • The DI container becomes your strategy registry.

No custom dictionary, no giant factory.

Wiring the payment strategies with keyed services

Let’s rewrite the registration for our payment example.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddKeyedTransient<IPaymentGateway, StripePaymentGateway>(PaymentProviderKey.Stripe);
builder.Services.AddKeyedTransient<IPaymentGateway, PaypalPaymentGateway>(PaymentProviderKey.Paypal);
builder.Services.AddKeyedTransient<IPaymentGateway, SandboxPaymentGateway>(PaymentProviderKey.Sandbox);

builder.Services.AddScoped<PaymentService>();

var app = builder.Build();

// routes here...

app.Run();

A small trick here: we use the same enum PaymentProviderKey for both the domain and the DI key. That way you don’t need another mapping layer.

Now let’s refactor PaymentService.

A clean PaymentService with keyed services

Instead of injecting each gateway, we inject the service provider (or a small wrapper) and resolve the correct gateway by key.

public interface IPaymentService
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default);
}

public sealed class PaymentService : IPaymentService
{
    private readonly IServiceProvider _serviceProvider;

    public PaymentService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        var gateway = _serviceProvider.GetRequiredKeyedService<IPaymentGateway>(request.Provider);

        return gateway.ChargeAsync(request, ct);
    }
}

What changed?

  • PaymentService only knows about IPaymentGateway, not about Stripe or PayPal directly.
  • No switch, no if/else.
  • Adding a new provider is only:
  • create a new class SomeNewGateway : IPaymentGateway
  • register it with AddKeyedTransient using a new enum value

PaymentService stays frozen.

In many code bases, having a class that you almost never touch is a good sign.

Simple flow: PaymentRequestPaymentServiceIPaymentGatewayStripe / PayPal / Sandbox boxes with small enum keys on the arrows.

Handling “unknown provider” and fallbacks

What if a wrong provider key comes in?

The GetRequiredKeyedService method will throw if there is no registration for the key. In a public API that’s a bit harsh, so you usually want a graceful response.

Wrap the lookup and handle the error:

public sealed class PaymentService : IPaymentService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<PaymentService> _logger;

    public PaymentService(IServiceProvider serviceProvider, ILogger<PaymentService> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        var gateway = _serviceProvider.GetKeyedService<IPaymentGateway>(request.Provider);

        if (gateway is null)
        {
            _logger.LogWarning("Payment provider {Provider} is not configured", request.Provider);

            return new PaymentResult
            {
                Success = false,
                Error = $"Payment provider '{request.Provider}' is not available."
            };
        }

        return await gateway.ChargeAsync(request, ct);
    }
}

Now the service:

  • Returns a clear error if the provider is missing in configuration.
  • Logs a warning so you can fix the registration.

If you want a fallback provider (for example always use Sandbox in non‑production), you can plug it in here too:

public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
{
    var gateway = _serviceProvider.GetKeyedService<IPaymentGateway>(request.Provider)
                  ?? _serviceProvider.GetRequiredKeyedService<IPaymentGateway>(PaymentProviderKey.Sandbox);

    return await gateway.ChargeAsync(request, ct);
}

Still no switch, just a simple fallback rule.

Selecting strategies from HTTP endpoints

Let’s say you expose an endpoint for payments.

app.MapPost("/payments", async (
    PaymentRequest request,
    IPaymentService paymentService,
    CancellationToken ct) =>
{
    var result = await paymentService.ChargeAsync(request, ct);

    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

The client sends "provider": "Stripe" (or another enum value), your backend maps it to PaymentProviderKey and PaymentService does the rest.

There is no extra plumbing in the endpoint layer.

If you want to drive the provider from an HTTP route instead of the body, you can keep the same idea:

app.MapPost("/payments/{provider}", async (
    [AsParameters] PaymentByRouteRequest request,
    IPaymentService paymentService,
    CancellationToken ct) =>
{
    var result = await paymentService.ChargeAsync(request.ToPaymentRequest(), ct);

    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

public sealed record PaymentByRouteRequest(string Provider, decimal Amount, string Currency)
{
    public PaymentRequest ToPaymentRequest() => new()
    {
        Amount = Amount,
        Currency = Currency,
        CustomerId = "from-auth-or-body",
        Provider = Enum.Parse<PaymentProviderKey>(Provider, ignoreCase: true)
    };
}

Again, no change in the strategy wiring. Only mapping from HTTP to domain.

Strategy pattern without custom factories

You might ask: “Why not register all IPaymentGateway implementations and use a dictionary?”

That works, but you end up building a mini‑container:

public sealed class PaymentGatewayFactory
{
    private readonly IReadOnlyDictionary<PaymentProviderKey, IPaymentGateway> _gateways;

    public PaymentGatewayFactory(IEnumerable<IPaymentGateway> gateways)
    {
        _gateways = gateways.ToDictionary(
            g => g switch
            {
                StripePaymentGateway  => PaymentProviderKey.Stripe,
                PaypalPaymentGateway  => PaymentProviderKey.Paypal,
                SandboxPaymentGateway => PaymentProviderKey.Sandbox,
                _ => throw new ArgumentOutOfRangeException(nameof(g), g, "Unknown gateway type")
            });
    }

    public IPaymentGateway Get(PaymentProviderKey key) => _gateways[key];
}

Now you have:

  • Type checks in the factory.
  • A place you must update every time you create a new gateway.

Keyed services remove this extra class completely. The DI container is your factory.

Your Strategy pattern becomes:

  • One interface.
  • Many implementations.
  • Keys to distinguish them.
  • DI to resolve by key.

That’s it.

Config‑driven provider selection

A common real‑world requirement: “Use PayPal for this tenant, Stripe for that tenant, Sandbox in staging.”

With keyed services this becomes very simple.

appsettings.json:

{
  "Payments": {
    "DefaultProvider": "Stripe",
    "TenantOverrides": {
      "tenant-a": "Paypal",
      "tenant-b": "Sandbox"
    }
  }
}

Options class:

public sealed class PaymentsOptions
{
    public string DefaultProvider { get; set; } = PaymentProviderKey.Stripe.ToString();

    public Dictionary<string, string> TenantOverrides { get; set; } = new();
}

Registration:

builder.Services.Configure<PaymentsOptions>(
    builder.Configuration.GetSection("Payments"));

builder.Services.AddScoped<IPaymentService, TenantAwarePaymentService>();

Implementation:

public sealed class TenantAwarePaymentService : IPaymentService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IOptions<PaymentsOptions> _options;

    public TenantAwarePaymentService(
        IServiceProvider serviceProvider,
        IOptions<PaymentsOptions> options)
    {
        _serviceProvider = serviceProvider;
        _options = options;
    }

    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        var providerKey = ResolveProviderForTenant(request.CustomerId);

        var gateway = _serviceProvider
            .GetRequiredKeyedService<IPaymentGateway>(providerKey);

        return gateway.ChargeAsync(request, ct);
    }

    private PaymentProviderKey ResolveProviderForTenant(string customerId)
    {
        // Real code: resolve tenant id from customer or context
        var tenantId = GetTenantIdForCustomer(customerId);

        var opts = _options.Value;

        if (opts.TenantOverrides.TryGetValue(tenantId, out var overrideValue))
        {
            return Enum.Parse<PaymentProviderKey>(overrideValue, ignoreCase: true);
        }

        return Enum.Parse<PaymentProviderKey>(opts.DefaultProvider, ignoreCase: true);
    }

    private static string GetTenantIdForCustomer(string customerId)
        => customerId.Split(':', 2)[0];
}

Notice how the tenant rules are completely separate from the gateway implementations.

You can change config, add tenants, flip default providers – no changes in StripePaymentGateway, PaypalPaymentGateway or the DI registrations.

Testing code that uses keyed strategies

Let’s write a small test for PaymentService without hitting real gateways.

We can build a lightweight ServiceCollection inside the test and register fakes.

[Fact]
public async Task PaymentService_uses_gateway_for_given_key()
{
    // Arrange
    var services = new ServiceCollection();

    var usedProviders = new List<PaymentProviderKey>();

    services.AddKeyedTransient<IPaymentGateway>(
        PaymentProviderKey.Stripe,
        (_, _) => new RecordingGateway(PaymentProviderKey.Stripe, usedProviders));

    services.AddKeyedTransient<IPaymentGateway>(
        PaymentProviderKey.Paypal,
        (_, _) => new RecordingGateway(PaymentProviderKey.Paypal, usedProviders));

    services.AddScoped<IPaymentService, PaymentService>();

    var provider = services.BuildServiceProvider();

    var paymentService = provider.GetRequiredService<IPaymentService>();

    var request = new PaymentRequest
    {
        Amount = 10,
        Currency = "USD",
        CustomerId = "tenant-a:123",
        Provider = PaymentProviderKey.Paypal
    };

    // Act
    await paymentService.ChargeAsync(request);

    // Assert
    Assert.Single(usedProviders);
    Assert.Equal(PaymentProviderKey.Paypal, usedProviders[0]);
}

file sealed class RecordingGateway : IPaymentGateway
{
    private readonly PaymentProviderKey _key;
    private readonly List<PaymentProviderKey> _usedProviders;

    public RecordingGateway(PaymentProviderKey key, List<PaymentProviderKey> usedProviders)
    {
        _key = key;
        _usedProviders = usedProviders;
    }

    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default)
    {
        _usedProviders.Add(_key);

        return Task.FromResult(new PaymentResult
        {
            Success = true,
            TransactionId = "test"
        });
    }
}

Key points:

  • You can register different fakes per key.
  • The test asserts that PaymentService picked the right one.
  • You don’t mock IServiceProvider or anything heavy.

This is often simpler and clearer than mocking a custom factory.

When keyed services are a good fit (and when they are not)

Keyed services work very well when:

  • You have one interface and several implementations.
  • A key from your domain clearly selects the implementation.
  • You want to add or remove strategies without touching the core service.
  • You need to plug different strategies per environment or tenant.

They might be overkill when:

  • You only have two implementations and don’t expect more.
  • The choice logic is complex and depends on many inputs (not just a single key).
  • You already use a pattern like chain of responsibility where the order of handlers matters more than their identity.

Also, don’t forget you can mix keyed services with other DI patterns:

  • Decorators.
  • Generic strategies.
  • Pipelines.

The point is not to push keyed services everywhere, but to use them where they keep the Strategy pattern simple and closed for modification.

FAQ: Strategy pattern with keyed services in .NET

Can I use strings as keys?

Yes. Keys are object, so strings work fine:
builder.Services.AddKeyedTransient<IPaymentGateway, StripePaymentGateway>("stripe");
I prefer enums for compiler help, but if you already have string codes in a database, strings are fine.

Will this work only in ASP.NET Core?

No. Keyed services come from the Microsoft.Extensions.DependencyInjection stack. You can use them in:
– console apps,
– workers,
– minimal APIs,
– any .NET app that uses the default DI container.

Is there a performance cost?

There is a small overhead for DI itself, same as usual. For most business systems this is not the bottleneck. If you are charging credit cards, the network call to the gateway is orders of magnitude slower than resolving a service.
If you ever hit performance limits, you can always cache resolved strategies in your own dictionary for hot paths.

Conclusion: Keep your Strategy pattern small and boring

Giant switch blocks and home‑grown factories tend to grow. Every new case adds more risk. With keyed services, the Strategy pattern stays small and boring:

  • One interface, many implementations.
  • Keys wired in DI.
  • A single lookup by key at runtime.

Adding a new strategy is mostly a registration task, not a refactor task.

If you look at your code base and see a service that knows about five or more implementations of the same interface, ask yourself: can this be a keyed Strategy instead?

Try this in your next feature: take a messy if/else that chooses between implementations, move that logic into keyed DI, and see how much cleaner your core service becomes.

And when you do, tell me: which part of your code base felt lighter after this change? I am genuinely curious.

Leave a Reply

Your email address will not be published. Required fields are marked *