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
- Prefer Constructor Injection: Makes dependencies explicit
- Avoid Service Locator: Don't inject IServiceProvider unless necessary
- Register Interfaces: Program to abstractions, not implementations
- Watch Lifetimes: Prevent captive dependencies
- Use Extension Methods: Organize related registrations
- Validate on Startup: Use
ValidateOnBuild()andValidateScopes() - 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.