Dependency Injection in ASP.NET Core: Mastering Service Lifetimes and Patterns

Dependency Injection in ASP.NET Core: Mastering Service Lifetimes and Patterns

Introduction

Dependency Injection (DI) is fundamental to building testable, maintainable ASP.NET Core applications. This guide covers service lifetimes (transient, scoped, singleton), understanding the built-in DI container, keyed services in .NET 8, factory patterns, avoiding common pitfalls like captive dependencies, and best practices for organizing and testing DI-powered applications.

Service Lifetimes

Transient Services

Created each time requested:

// Registration
builder.Services.AddTransient<IEmailService, EmailService>();

// Usage
public class OrderController : ControllerBase
{
    private readonly IEmailService _emailService;
    
    public OrderController(IEmailService emailService)
    {
        _emailService = emailService;  // New instance
    }
}

// Use case: Stateless services, lightweight operations
public class EmailService : IEmailService
{
    public async Task SendAsync(string to, string subject, string body)
    {
        // No internal state - safe as transient
        await SmtpClient.SendMailAsync(to, subject, body);
    }
}

Scoped Services

One instance per HTTP request:

// Registration
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

// Use case: Database contexts, per-request state
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;
    
    public OrderRepository(AppDbContext context)
    {
        _context = context;  // Shared within request
    }
    
    public async Task<Order> GetByIdAsync(int id)
    {
        return await _context.Orders.FindAsync(id);
    }
}

// Multiple services share same DbContext instance
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly ICustomerRepository _customers;
    
    public OrderService(
        IOrderRepository orders,
        ICustomerRepository customers)
    {
        _orders = orders;
        _customers = customers;
        // Both repositories use same DbContext instance
    }
}

Singleton Services

One instance for application lifetime:

// Registration
builder.Services.AddSingleton<ICache, MemoryCache>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);

// Use case: Expensive to create, thread-safe, stateless
public class MemoryCache : ICache
{
    private readonly ConcurrentDictionary<string, object> _cache = new();
    
    public void Set(string key, object value)
    {
        _cache[key] = value;  // Thread-safe
    }
    
    public T Get<T>(string key)
    {
        return _cache.TryGetValue(key, out var value) 
            ? (T)value 
            : default;
    }
}

Captive Dependencies (Anti-Pattern)

The Problem

❌ Singleton capturing Scoped dependency:

// DON'T DO THIS
builder.Services.AddSingleton<CacheService>();
builder.Services.AddScoped<AppDbContext>();

public class CacheService  // Singleton
{
    private readonly AppDbContext _context;  // Scoped - PROBLEM!
    
    public CacheService(AppDbContext context)
    {
        _context = context;
        // DbContext stays alive for app lifetime
        // Memory leak, stale data, threading issues
    }
}

The Solution

✅ Use IServiceScopeFactory:

// Correct approach
builder.Services.AddSingleton<CacheService>();
builder.Services.AddScoped<AppDbContext>();

public class CacheService
{
    private readonly IServiceScopeFactory _scopeFactory;
    
    public CacheService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }
    
    public async Task<User> GetUserAsync(int id)
    {
        using var scope = _scopeFactory.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        return await context.Users.FindAsync(id);
    }
}

Keyed Services (.NET 8)

Registration

Multiple implementations:

// Register multiple implementations with keys
builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
builder.Services.AddKeyedScoped<IPaymentProcessor, SquareProcessor>("square");

Consumption

Inject by key:

// Option 1: Constructor injection with [FromKeyedServices]
public class PaymentController : ControllerBase
{
    private readonly IPaymentProcessor _stripeProcessor;
    private readonly IPaymentProcessor _paypalProcessor;
    
    public PaymentController(
        [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor,
        [FromKeyedServices("paypal")] IPaymentProcessor paypalProcessor)
    {
        _stripeProcessor = stripeProcessor;
        _paypalProcessor = paypalProcessor;
    }
}

// Option 2: Resolve at runtime
public class PaymentService
{
    private readonly IServiceProvider _serviceProvider;
    
    public PaymentService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public async Task ProcessPaymentAsync(Order order)
    {
        var processor = _serviceProvider
            .GetRequiredKeyedService<IPaymentProcessor>(order.PaymentMethod);
        
        await processor.ProcessAsync(order.Total);
    }
}

Factory Patterns

Simple Factory

Factory service:

public interface INotificationFactory
{
    INotificationService Create(NotificationType type);
}

public class NotificationFactory : INotificationFactory
{
    private readonly IServiceProvider _serviceProvider;
    
    public NotificationFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public INotificationService Create(NotificationType type)
    {
        return type switch
        {
            NotificationType.Email => _serviceProvider.GetRequiredService<EmailNotificationService>(),
            NotificationType.Sms => _serviceProvider.GetRequiredService<SmsNotificationService>(),
            NotificationType.Push => _serviceProvider.GetRequiredService<PushNotificationService>(),
            _ => throw new ArgumentException($"Unknown notification type: {type}")
        };
    }
}

// Registration
builder.Services.AddTransient<EmailNotificationService>();
builder.Services.AddTransient<SmsNotificationService>();
builder.Services.AddTransient<PushNotificationService>();
builder.Services.AddSingleton<INotificationFactory, NotificationFactory>();

Func Factory

Delegate injection:

// Registration
builder.Services.AddTransient<ExpensiveService>();
builder.Services.AddSingleton<Func<ExpensiveService>>(sp => 
    () => sp.GetRequiredService<ExpensiveService>());

// Usage
public class WorkerService
{
    private readonly Func<ExpensiveService> _serviceFactory;
    
    public WorkerService(Func<ExpensiveService> serviceFactory)
    {
        _serviceFactory = serviceFactory;
    }
    
    public async Task ProcessBatchAsync(List<Item> items)
    {
        foreach (var item in items)
        {
            // Create new instance for each item
            var service = _serviceFactory();
            await service.ProcessAsync(item);
        }
    }
}

Named Options Pattern

Multiple configurations:

// Configuration
builder.Services.Configure<SmtpSettings>("Primary", 
    builder.Configuration.GetSection("Smtp:Primary"));
builder.Services.Configure<SmtpSettings>("Backup", 
    builder.Configuration.GetSection("Smtp:Backup"));

// Usage
public class EmailService
{
    private readonly IOptionsMonitor<SmtpSettings> _options;
    
    public EmailService(IOptionsMonitor<SmtpSettings> options)
    {
        _options = options;
    }
    
    public async Task SendAsync(string to, string body, string server = "Primary")
    {
        var settings = _options.Get(server);
        await SendEmailAsync(to, body, settings);
    }
}

Registration Patterns

Extension Methods

Organized registration:

// ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<ICustomerService, CustomerService>();
        services.AddScoped<IInventoryService, InventoryService>();
        return services;
    }
    
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Default")));
        
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        
        return services;
    }
}

// Program.cs
builder.Services
    .AddApplicationServices()
    .AddInfrastructure(builder.Configuration);

TryAdd Methods

Conditional registration:

// Register only if not already registered
services.TryAddScoped<IEmailService, EmailService>();

// Try add multiple
services.TryAddEnumerable(
    ServiceDescriptor.Scoped<IHostedService, MetricsService>());

// Replace registration
services.Replace(ServiceDescriptor.Singleton<ICache, RedisCache>());

// Remove registration
services.RemoveAll<IEmailService>();

Assembly Scanning

Auto-registration:

// Register all implementations of an interface
public static IServiceCollection AddServicesFromAssembly(
    this IServiceCollection services,
    Assembly assembly)
{
    var serviceTypes = assembly.GetTypes()
        .Where(t => t.IsClass && !t.IsAbstract)
        .SelectMany(t => t.GetInterfaces(), (impl, iface) => new { impl, iface })
        .Where(x => x.iface.Name == $"I{x.impl.Name}");
    
    foreach (var service in serviceTypes)
    {
        services.AddScoped(service.iface, service.impl);
    }
    
    return services;
}

// Usage
builder.Services.AddServicesFromAssembly(typeof(Program).Assembly);

Decorator Pattern

Wrapping services:

// Base service
public interface IOrderService
{
    Task<Order> CreateOrderAsync(Order order);
}

public class OrderService : IOrderService
{
    public async Task<Order> CreateOrderAsync(Order order)
    {
        // Core logic
        return order;
    }
}

// Decorator with logging
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 async Task<Order> CreateOrderAsync(Order order)
    {
        _logger.LogInformation("Creating order {OrderId}", order.Id);
        var result = await _inner.CreateOrderAsync(order);
        _logger.LogInformation("Order {OrderId} created", result.Id);
        return result;
    }
}

// Registration with Scrutor
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();

Testing with DI

Unit Testing

Mock dependencies:

public class OrderServiceTests
{
    [Fact]
    public async Task CreateOrder_ValidOrder_ReturnsCreatedOrder()
    {
        // Arrange
        var mockRepo = new Mock<IOrderRepository>();
        mockRepo.Setup(r => r.AddAsync(It.IsAny<Order>()))
            .ReturnsAsync((Order o) => o);
        
        var service = new OrderService(mockRepo.Object);
        
        // Act
        var order = new Order { CustomerId = 1, Total = 100m };
        var result = await service.CreateOrderAsync(order);
        
        // Assert
        Assert.NotNull(result);
        mockRepo.Verify(r => r.AddAsync(It.IsAny<Order>()), Times.Once);
    }
}

Integration Testing

WebApplicationFactory:

public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    
    public OrderApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace dependencies for testing
                services.RemoveAll<IEmailService>();
                services.AddSingleton<IEmailService, FakeEmailService>();
            });
        });
    }
    
    [Fact]
    public async Task GetOrder_ExistingId_ReturnsOrder()
    {
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/api/orders/1");
        
        response.EnsureSuccessStatusCode();
        var order = await response.Content.ReadFromJsonAsync<Order>();
        Assert.NotNull(order);
    }
}

Best Practices

  1. Prefer Constructor Injection: Makes dependencies explicit
  2. Avoid Service Locator: Don't inject IServiceProvider unless necessary
  3. Register Interfaces: Program to abstractions, not implementations
  4. Watch Lifetimes: Prevent captive dependencies
  5. Use Extension Methods: Organize related registrations
  6. Validate on Startup: Use ValidateOnBuild() and ValidateScopes()
  7. Keep Constructors Simple: Move complex logic to factory methods

Validation

Detect configuration issues:

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

// Throws on startup if:
// - Captive dependencies detected
// - Missing registrations
// - Circular dependencies

Troubleshooting

Circular Dependencies:

// ❌ Circular dependency
public class ServiceA
{
    public ServiceA(ServiceB serviceB) { }
}

public class ServiceB
{
    public ServiceB(ServiceA serviceA) { }
}

// ✅ Break the cycle
public class ServiceA
{
    private ServiceB _serviceB;
    
    public void Initialize(ServiceB serviceB)
    {
        _serviceB = serviceB;
    }
}

Captive Dependency Detection:

// Enable validation
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;  // Detects captive dependencies
});

Key Takeaways

  • Transient for stateless, Scoped for per-request, Singleton for application-wide
  • Avoid captive dependencies by respecting lifetime hierarchies
  • Keyed services enable multiple implementations with clean resolution
  • Factory patterns provide control over instance creation
  • Extension methods organize registrations and improve maintainability

Next Steps

  • Explore third-party containers (Autofac, Lamar) for advanced scenarios
  • Implement property injection for optional dependencies
  • Learn interceptors for cross-cutting concerns (logging, caching)
  • Use Scrutor for assembly scanning and decoration

Additional Resources


Inject dependencies, not problems.