ASP.NET Core  

Beyond Basics: Mastering Advanced Dependency Injection Patterns in ASP.NET Core

Introduction

Dependency Injection (DI) is one of the most powerful features in ASP.NET Core. It helps you build loosely coupled, testable, and maintainable applications.
Most developers know how to inject services using the AddScoped, AddSingleton, or AddTransient methods—but very few truly explore the advanced patterns that make large-scale enterprise applications more modular and efficient.

In this article, we’ll go beyond the basics and explore real-world DI techniques you can apply immediately in your ASP.NET Core projects.

Table of Contents

  1. What is Dependency Injection (Quick Recap)

  2. Common Lifetime Scopes (Recap + Practical Use)

  3. Advanced DI Scenarios

    • Conditional Service Injection

    • Named Service Registration

    • Registering Open Generics

    • Using Factory Methods

    • Module-based DI (Feature-level Registration)

    • Runtime Service Replacement

  4. Handling Cross-Cutting Concerns with DI

  5. Testing and Mocking Dependencies

  6. Best Practices and Pitfalls

  7. Technical Workflow (Flowchart)

1. What is Dependency Injection (Quick Recap)

Dependency Injection is a design pattern that allows objects to receive their dependencies rather than creating them directly.
ASP.NET Core has a built-in DI container, meaning we can directly inject services into controllers, middlewares, and background tasks without external libraries.

Example

public class OrderService
{
    private readonly IPaymentGateway _paymentGateway;

    public OrderService(IPaymentGateway paymentGateway)
    {
        _paymentGateway = paymentGateway;
    }

    public void ProcessOrder()
    {
        _paymentGateway.Pay();
    }
}

You register dependencies like this:

services.AddScoped<IPaymentGateway, RazorpayGateway>();
services.AddScoped<OrderService>();

2. Common Lifetime Scopes (Recap + Practical Use)

LifetimeDescriptionExample Use
SingletonSame instance for entire appCaching, logging
ScopedSame instance per HTTP requestUnit of Work, DbContext
TransientNew instance every timeLightweight services

Example

services.AddSingleton<ILogService, LogService>();
services.AddScoped<IRepository, Repository>();
services.AddTransient<IEmailService, EmailService>();

3. Advanced DI Scenarios

a. Conditional Service Injection

Sometimes you want to use different implementations of an interface based on configuration or environment.

if (builder.Environment.IsDevelopment())
    services.AddScoped<IEmailService, DevEmailService>();
else
    services.AddScoped<IEmailService, ProdEmailService>();

This allows dynamic injection depending on environment or feature toggles.

b. Named Service Registration

ASP.NET Core doesn’t natively support named services, but you can achieve it via Func factories.

services.AddScoped<DevEmailService>();
services.AddScoped<ProdEmailService>();

services.AddScoped<Func<string, IEmailService>>(sp => key =>
{
    return key switch
    {
        "dev" => sp.GetRequiredService<DevEmailService>(),
        "prod" => sp.GetRequiredService<ProdEmailService>(),
        _ => throw new ArgumentException("Invalid key")
    };
});

Usage

public class EmailController
{
    private readonly IEmailService _emailService;

    public EmailController(Func<string, IEmailService> serviceFactory)
    {
        _emailService = serviceFactory("prod");
    }
}

c. Registering Open Generics

For repository or caching layers that are generic, register using open generics:

services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

This allows:

var userRepo = serviceProvider.GetRequiredService<IRepository<User>>();

d. Using Factory Methods

You can create dynamic services with factories:

services.AddScoped<IDatabase>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    string connStr = config.GetConnectionString("DefaultConnection");
    return new SqlDatabase(connStr);
});

This is useful when services depend on runtime configuration.

e. Module-Based DI (Feature-Level Registration)

In large projects, organizing DI by module helps maintain structure.

Example structure

/Features
  /Orders
     - OrderService.cs
     - OrderRepository.cs
     - OrderModule.cs

OrderModule.cs

public static class OrderModule
{
    public static void Register(IServiceCollection services)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IOrderRepository, OrderRepository>();
    }
}

In Program.cs

OrderModule.Register(services);

This approach improves scalability and maintainability.

f. Runtime Service Replacement

In testing or maintenance mode, you may need to override services dynamically.

var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IMailer));
if (descriptor != null)
    services.Remove(descriptor);

services.AddScoped<IMailer, MockMailer>();

This is helpful for A/B testing or integration tests.

4. Handling Cross-Cutting Concerns with DI

Instead of injecting logging or caching manually in every service, you can wrap it using decorators.

Example (Simple Decorator Pattern):

public class LoggingOrderService : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger<LoggingOrderService> _logger;

    public LoggingOrderService(IOrderService inner, ILogger<LoggingOrderService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public void ProcessOrder()
    {
        _logger.LogInformation("Order processing started");
        _inner.ProcessOrder();
        _logger.LogInformation("Order processing completed");
    }
}

You can register the decorator dynamically using Scrutor:

services.Decorate<IOrderService, LoggingOrderService>();

5. Testing and Mocking Dependencies

Dependency Injection makes unit testing simpler by replacing real implementations with mocks or stubs.

Example with xUnit + Moq

var mockPayment = new Mock<IPaymentGateway>();
mockPayment.Setup(p => p.Pay()).Returns(true);

var service = new OrderService(mockPayment.Object);
service.ProcessOrder();

6. Best Practices and Pitfalls

Do’s

  • Prefer constructor injection.

  • Use Scoped lifetime for database contexts.

  • Group registrations in modules.

Don’ts

  • Avoid service locator pattern.

  • Don’t inject too many dependencies (more than 4 = code smell).

  • Avoid creating disposable transients.

7. Technical Workflow (Flowchart)

Dependency Injection Flow

+--------------------+
| Program.cs Startup |
+---------+----------+
          |
          v
+--------------------------+
| Service Registration     |
| (AddScoped, Singleton)   |
+---------+----------------+
          |
          v
+--------------------------+
| ServiceProvider Builds   |
+---------+----------------+
          |
          v
+--------------------------+
| Controller/Service Uses  |
| Dependencies Injected    |
+--------------------------+

Conclusion

Advanced Dependency Injection patterns in ASP.NET Core help you go beyond “just working code” to scalable, modular, and maintainable enterprise-level architecture.

By implementing techniques like module-based registration, decorators, open generics, and runtime service replacement—you can handle complexity while keeping your code clean and testable.

In real-world enterprise systems, mastering DI is not just about knowing syntax—it’s about designing for flexibility and future change.