Introduction
Dependency Injection (DI) is a fundamental concept in .NET Core that promotes loose coupling and testability. This article explores best practices for implementing DI effectively in your applications.
Understanding Service Lifetimes
Transient Services
Transient services are created each time they’re requested. Use transient lifetime for lightweight, stateless services.
services.AddTransient<IEmailService, EmailService>();
Scoped Services
Scoped services are created once per client request. This is the default lifetime for Entity Framework DbContext.
services.AddScoped<IUserRepository, UserRepository>();
Singleton Services
Singleton services are created once and reused throughout the application lifetime.
services.AddSingleton<ICacheService, MemoryCacheService>();
Registration Patterns
Interface Segregation
Keep interfaces focused and single-purpose:
public interface IUserReader
{
Task<User> GetByIdAsync(int id);
Task<IEnumerable<User>> GetAllAsync();
}
public interface IUserWriter
{
Task<int> CreateAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(int id);
}
Factory Pattern
Use factories for complex object creation:
services.AddTransient<Func<string, IPaymentProcessor>>(serviceProvider => key =>
{
return key switch
{
"stripe" => serviceProvider.GetService<StripePaymentProcessor>(),
"paypal" => serviceProvider.GetService<PayPalPaymentProcessor>(),
_ => throw new NotSupportedException($"Payment processor {key} not supported")
};
});
Common Pitfalls
Captive Dependencies
Avoid injecting services with shorter lifetimes into services with longer lifetimes:
// ❌ Bad: Scoped service in Singleton
public class SingletonService
{
private readonly IScopedService _scopedService;
public SingletonService(IScopedService scopedService)
{
_scopedService = scopedService; // This will cause issues
}
}
// ✅ Good: Use IServiceScopeFactory
public class SingletonService
{
private readonly IServiceScopeFactory _scopeFactory;
public SingletonService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task DoWorkAsync()
{
using var scope = _scopeFactory.CreateScope();
var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
await scopedService.ProcessAsync();
}
}
Service Locator Anti-Pattern
Avoid using IServiceProvider directly in your business logic:
// ❌ Bad: Service Locator
public class OrderService
{
private readonly IServiceProvider _serviceProvider;
public OrderService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void ProcessOrder()
{
var emailService = _serviceProvider.GetService<IEmailService>();
// ...
}
}
// ✅ Good: Constructor Injection
public class OrderService
{
private readonly IEmailService _emailService;
public OrderService(IEmailService emailService)
{
_emailService = emailService;
}
public void ProcessOrder()
{
// Use _emailService directly
}
}
Advanced Techniques
Options Pattern
Use the Options pattern for configuration:
public class EmailSettings
{
public string SmtpServer { get; set; }
public int Port { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}
// Registration
services.Configure<EmailSettings>(configuration.GetSection("Email"));
services.AddTransient<IEmailService, SmtpEmailService>();
// Usage
public class SmtpEmailService : IEmailService
{
private readonly EmailSettings _settings;
public SmtpEmailService(IOptions<EmailSettings> options)
{
_settings = options.Value;
}
}
Decorator Pattern
Implement cross-cutting concerns using decorators:
public class CachedUserRepository : IUserRepository
{
private readonly IUserRepository _innerRepository;
private readonly ICacheService _cache;
public CachedUserRepository(IUserRepository innerRepository, ICacheService cache)
{
_innerRepository = innerRepository;
_cache = cache;
}
public async Task<User> GetByIdAsync(int id)
{
var cacheKey = $"user_{id}";
if (_cache.TryGet(cacheKey, out User cachedUser))
return cachedUser;
var user = await _innerRepository.GetByIdAsync(id);
_cache.Set(cacheKey, user, TimeSpan.FromMinutes(5));
return user;
}
}
Testing with DI
Using Test Doubles
Create testable code by injecting dependencies:
[Test]
public async Task ProcessOrder_SendsEmailNotification()
{
// Arrange
var mockEmailService = new Mock<IEmailService>();
var orderService = new OrderService(mockEmailService.Object);
var order = new Order { Id = 1, CustomerEmail = "[email protected]" };
// Act
await orderService.ProcessOrderAsync(order);
// Assert
mockEmailService.Verify(x => x.SendAsync(
It.Is<string>(email => email == "[email protected]"),
It.IsAny<string>(),
It.IsAny<string>()
), Times.Once);
}
Conclusion
Proper implementation of dependency injection in .NET Core applications leads to more maintainable, testable, and flexible code. By following these best practices, you can avoid common pitfalls and build robust applications that are easy to extend and modify.
Comments