In the previous post Utilizing Factory and Dependency Injection Patterns to Preserve SOLID Principles, we used a traditional factory to select a payment provider at runtime while keeping the rest of the application clean and testable.
With .NET 8, the built-in Dependency Injection container now provides first-class support for keyed services. This allows us to remove the factory entirely and let the container handle runtime selection directly, without sacrificing clarity or SOLID principles.
This approach works particularly well when runtime selection is based on a simple key, such as a string or enum.
The core idea
- The DI container owns object creation and lifetime
- Each implementation is registered with a key
- The application resolves the correct implementation at runtime using that key
No factories. No switch statements. No custom selection infrastructure.
Updated file structure
The structure becomes even simpler when using keyed services.
test-di-keyed
│
├── Interfaces
│ └── IPaymentService.cs
│
├── Services
│ ├── CheckoutService.cs
│ ├── StripePayment.cs
│ └── PaypalPayment.cs
│
├── Bootstrap.cs
└── Program.cs
Program.cs
Program.cs remains a thin entry point. Its responsibility is limited to bootstrapping the application and triggering execution.
using System;
using Microsoft.Extensions.DependencyInjection;
using test_di_keyed.Services;
namespace test_di_keyed;
internal class Program
{
private static void Main(string[] args)
{
IServiceProvider provider = Bootstrap.BuildContainer();
var checkout = provider.GetRequiredService<CheckoutService>();
Console.WriteLine("Choose payment: stripe / paypal");
string input = (Console.ReadLine() ?? "").Trim();
try
{
checkout.Pay(input, 499.95m);
Console.WriteLine("Payment completed successfully.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
Bootstrap.cs – Composition Root
Bootstrap.cs is the composition root. This is the only place where concrete implementations and keys are defined.
In .NET 9, keyed services allow us to register multiple implementations of the same abstraction under different keys.
using System;
using Microsoft.Extensions.DependencyInjection;
using test_di_keyed.Interfaces;
using test_di_keyed.Services;
namespace test_di_keyed;
internal static class Bootstrap
{
public static IServiceProvider BuildContainer()
{
var services = new ServiceCollection();
// Payment providers (keyed)
services.AddKeyedTransient<IPaymentService, StripePayment>("stripe");
services.AddKeyedTransient<IPaymentService, PaypalPayment>("paypal");
// Application services
services.AddTransient<CheckoutService>();
return services.BuildServiceProvider();
}
}
Interfaces.IPaymentService
The payment abstraction remains unchanged. The rest of the application depends only on this interface.
namespace test_di_keyed.Interfaces;
public interface IPaymentService
{
string Name { get; }
void Pay(decimal amount);
}
Services.CheckoutService
CheckoutService coordinates the payment flow. Instead of calling a factory, it asks the DI container for the keyed implementation.
This introduces a controlled and explicit dependency on the container, but avoids additional infrastructure.
using System;
using Microsoft.Extensions.DependencyInjection;
using test_di_keyed.Interfaces;
namespace test_di_keyed.Services;
public class CheckoutService
{
private readonly IServiceProvider _provider;
public CheckoutService(IServiceProvider provider)
{
_provider = provider;
}
public void Pay(string paymentType, decimal amount)
{
var payment =
_provider.GetRequiredKeyedService<IPaymentService>(paymentType);
Console.WriteLine($"Using {payment.Name}...");
payment.Pay(amount);
}
}
Payment implementations
The concrete payment providers are simple, focused, and unaware of how they are selected.
using System;
using test_di_keyed.Interfaces;
namespace test_di_keyed.Services;
public class StripePayment : IPaymentService
{
public string Name => "Stripe";
public void Pay(decimal amount)
{
Console.WriteLine($"Processing Stripe payment of {amount:C}");
}
}
using System;
using test_di_keyed.Interfaces;
namespace test_di_keyed.Services;
public class PaypalPayment : IPaymentService
{
public string Name => "Paypal";
public void Pay(decimal amount)
{
Console.WriteLine($"Processing Paypal payment of {amount:C}");
}
}
How this preserves SOLID
- Single Responsibility: Each class has one clear purpose
- Open / Closed: New providers are added via registration, not code changes
- Liskov Substitution: All providers conform to the same abstraction
- Interface Segregation: Consumers depend only on what they use
- Dependency Inversion: High-level code depends on abstractions, not implementations
Trade-offs
Using keyed services introduces a controlled form of service location via IServiceProvider. In practice, this is often acceptable for:
- simple runtime selection
- edge-layer orchestration
- infrastructure-driven workflows
For more complex domains, or when selection logic becomes business logic, a router or strategy-based approach may still be preferable.
Conclusion
Keyed services provide a clean and pragmatic alternative to traditional factories.
When runtime selection is straightforward, this approach reduces boilerplate, removes unnecessary abstractions, and keeps the codebase aligned with modern Dependency Injection practices.
Factories are still useful in scenarios involving complex object construction or runtime parameters, but for simple selection, they are no longer required.