Design Patterns & Practices  

Factory Pattern + Dependency Injection in .NET

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:

  • Dependency Injection handles object creation and lifetime management

  • Factory Pattern handles runtime selection logic

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:

  • Create a new payment service

  • Register it in DI

  • Add one mapping in the factory

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.