Dependency Injection Best Practices in .NET Core

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