Building .NET applications inevitably leads to a common problem. Code starts clean, then requirements change. What began as a simple payment feature suddenly needs to support credit cards, UPI, PayPal, and whatever comes next week. That clean code from last month becomes a tangled mess of if-else statements.
This is where combining the Factory Pattern with Dependency Injection becomes invaluable. Not from academic theory, but from solving real problems in production systems.
Limitation of Dependency Injection
Dependency Injection is powerful and widely used in ASP.NET Core. It works exceptionally well when implementations are known at compile time.
The limitation appears when runtime decisions are required, such as selecting a payment method based on user choice. At that point, plain DI starts to feel restrictive, and developers often introduce conditional logic or break architectural boundaries to compensate.
Why Factory + DI Works Better
Each pattern addresses a different concern:
When combined, they remove the need for scattered new keywords, avoid static dependencies, and keep the architecture flexible and maintainable.
A Practical Payment System Example
Consider a checkout flow where users select a payment method at runtime.
Step 1: Define the Contract
public interface IPaymentService
{
void Pay(decimal amount);
}
The rest of the application depends only on this interface.
Step 2: Create Implementations
public class CreditCardPaymentService : IPaymentService
{
public void Pay(decimal amount)
{
Console.WriteLine($"Paid {amount} using Credit Card");
}
}
public class UpiPaymentService : IPaymentService
{
public void Pay(decimal amount)
{
Console.WriteLine($"Paid {amount} using UPI");
}
}
public class PaypalPaymentService : IPaymentService
{
public void Pay(decimal amount)
{
Console.WriteLine($"Paid {amount} using PayPal");
}
}
Each implementation has a single responsibility and no hidden dependencies.
Step 3: Implement the Factory
The factory delegates object creation to the DI container while handling runtime selection.
public interface IPaymentServiceFactory
{
IPaymentService Get(string paymentType);
}
public class PaymentServiceFactory : IPaymentServiceFactory
{
private readonly IServiceProvider _serviceProvider;
public PaymentServiceFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IPaymentService Get(string paymentType)
{
return paymentType.ToUpper() switch
{
"CARD" => _serviceProvider.GetRequiredService<CreditCardPaymentService>(),
"UPI" => _serviceProvider.GetRequiredService<UpiPaymentService>(),
"PAYPAL" => _serviceProvider.GetRequiredService<PaypalPaymentService>(),
_ => throw new ArgumentException("Invalid payment type")
};
}
}
This keeps all dependencies managed by DI while allowing runtime flexibility.
Step 4: Register Services
services.AddTransient<CreditCardPaymentService>();
services.AddTransient<UpiPaymentService>();
services.AddTransient<PaypalPaymentService>();
services.AddSingleton<IPaymentServiceFactory, PaymentServiceFactory>();
The configuration clearly defines what the container manages.
Step 5: Use the Factory
var factory = serviceProvider.GetRequiredService<IPaymentServiceFactory>();
var paymentService = factory.Get("UPI");
paymentService.Pay(1500);
Key outcomes:
No conditional logic in business layers
No tight coupling to concrete implementations
No scattered object creation
This is clean architecture applied in practice.
Why This Approach Scales
Adding new payment methods requires minimal change:
Existing logic remains untouched. Testing is simpler because everything depends on interfaces, making mocking and isolation straightforward.
Advanced Factory Variation
To fully embrace the Open/Closed Principle, the factory can eliminate the switch statement entirely.
public class PaymentServiceFactory : IPaymentServiceFactory
{
private readonly IDictionary<string, IPaymentService> _services;
public PaymentServiceFactory(IEnumerable<IPaymentService> services)
{
_services = services.ToDictionary(
s => s.GetType().Name.Replace("PaymentService", "").ToUpper()
);
}
public IPaymentService Get(string paymentType)
{
return _services[paymentType.ToUpper()];
}
}
New implementations are picked up automatically once registered with DI.
When to Use This Pattern
Use this approach when:
Multiple implementations exist for the same interface
Selection happens at runtime
The system is expected to evolve
Testability and maintainability matter
Avoid it when:
Only one implementation exists
Everything is determined at compile time
The logic is simple and static
Final Thoughts
Design patterns are practical tools, not academic exercises. Combining the Factory Pattern with Dependency Injection solves real-world problems that appear as systems grow.
This approach keeps code clean, stable, and adaptable. It allows teams to add features without destabilizing existing behavior, which is exactly what production systems require.