Dependency Injection in ASP.NET Core


Dependency Injection (DI) is a fundamental design pattern and a core feature of ASP.NET Core that enables you to write more maintainable, testable, and loosely coupled applications. Instead of having classes create their own dependencies directly, dependencies are "injected" from the outside, typically through constructors, properties, or method parameters.
This inversion of control (IoC) pattern has been a cornerstone of modern software architecture for decades, and ASP.NET Core was built with DI in mind from the ground up. The framework includes a lightweight, built-in IoC container that makes implementing DI straightforward, though you can also integrate third-party containers like Autofac, Unity, or Ninject for more advanced scenarios.
In this comprehensive guide, we'll explore how to leverage dependency injection effectively in your ASP.NET Core applications, covering everything from basic registration to advanced patterns and best practices.
Consider a traditional approach where classes create their dependencies directly:
public class UserService
{
private readonly EmailService _emailService;
private readonly DatabaseContext _context;
public UserService()
{
_emailService = new EmailService();
_context = new DatabaseContext();
}
public async Task CreateUserAsync(User user)
{
await _context.Users.AddAsync(user);
await _context.SaveChangesAsync();
await _emailService.SendWelcomeEmailAsync(user.Email);
}
}
This approach has several problems:
UserService is directly dependent on concrete implementations, making it difficult to change or extend.EmailService or DatabaseContext with mock objects for unit testing.UserService class.With dependency injection, dependencies are provided from outside:
public interface IEmailService
{
Task SendWelcomeEmailAsync(string email);
}
public interface IUserRepository
{
Task AddUserAsync(User user);
Task SaveChangesAsync();
}
public class UserService
{
private readonly IEmailService _emailService;
private readonly IUserRepository _userRepository;
public UserService(IEmailService emailService, IUserRepository userRepository)
{
_emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
}
public async Task CreateUserAsync(User user)
{
await _userRepository.AddUserAsync(user);
await _userRepository.SaveChangesAsync();
await _emailService.SendWelcomeEmailAsync(user.Email);
}
}
Now UserService depends on abstractions (IEmailService and IUserRepository), making it:
UserService)ASP.NET Core includes a built-in, lightweight dependency injection container that is sufficient for most applications. The container manages the creation and lifetime of service instances, automatically resolving dependencies when you request a service.
Services are registered in the Program.cs file (or Startup.cs in older versions) using the IServiceCollection:
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
var app = builder.Build();
app.MapGet("/user/{id}", async (int id, IUserService userService) => {
var user = await userService.GetUserByIdAsync(id);
return user != null ? Results.Ok(user) : Results.NotFound();
});
app.Run();
The IServiceCollection interface provides several extension methods for registering services:
builder.Services.AddScoped<IUserService, UserService>();
This registers UserService as the implementation for IUserService. When a component requests IUserService, the container will create and inject an instance of UserService.
builder.Services.AddScoped<UserService>();
You can register a concrete type directly, though this makes it harder to swap implementations later. This is useful for types that don't have interfaces or are not meant to be abstracted.
For services that require complex initialization, you can use a factory function:
builder.Services.AddScoped<IEmailService>(serviceProvider =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var smtpServer = configuration["Email:SmtpServer"];
return new EmailService(smtpServer);
});
You can register multiple implementations and retrieve them as a collection:
builder.Services.AddScoped<IMessageSender, EmailSender>();
builder.Services.AddScoped<IMessageSender, SmsSender>();
builder.Services.AddScoped<IMessageSender, PushNotificationSender>();
// Later, inject IEnumerable<IMessageSender> to get all implementations
public class NotificationService
{
private readonly IEnumerable<IMessageSender> _senders;
public NotificationService(IEnumerable<IMessageSender> senders)
{
_senders = senders;
}
public async Task SendNotificationAsync(string message)
{
foreach (var sender in _senders)
{
await sender.SendAsync(message);
}
}
}
The options pattern is commonly used with dependency injection:
// In Program.cs
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
// In your service
public class EmailService : IEmailService
{
private readonly EmailOptions _options;
public EmailService(IOptions<EmailOptions> options)
{
_options = options.Value;
}
}
One of the most important aspects of dependency injection is understanding service lifetimes. ASP.NET Core supports three lifetimes, each serving different purposes:
A new instance is created every time the service is requested. This is the most lightweight option.
builder.Services.AddTransient<IValidator, UserValidator>();
Use Transient when:
Example:
public class DataProcessor
{
private readonly IValidator _validator;
public DataProcessor(IValidator validator)
{
_validator = validator;
}
public void Process(string data)
{
if (_validator.IsValid(data))
{
// Process data
}
}
}
A new instance is created once per HTTP request (or per scope in non-web scenarios). The same instance is reused throughout the request lifecycle.
builder.Services.AddScoped<IUserRepository, UserRepository>();
Use Scoped when:
Example:
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentService _paymentService;
public OrderService(IOrderRepository orderRepository, IPaymentService paymentService)
{
_orderRepository = orderRepository;
_paymentService = paymentService;
}
public async Task<Order> CreateOrderAsync(Order order)
{
// Both repository and payment service share the same DbContext instance
// if they both depend on it, ensuring transactional consistency
await _orderRepository.AddAsync(order);
await _paymentService.ProcessPaymentAsync(order.Payment);
return order;
}
}
A single instance is created for the entire application lifetime. The same instance is reused across all requests.
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
Use Singleton when:
Example:
public class CacheService : ICacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();
public void Set(string key, object value)
{
_cache[key] = value;
}
public T Get<T>(string key)
{
return _cache.TryGetValue(key, out var value) ? (T)value : default(T);
}
}
⚠️ Important Warning: Never inject a scoped service into a singleton service directly, as this can cause issues with service disposal and state management. If you must use a scoped service within a singleton, use IServiceScopeFactory:
public class BackgroundService : IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
public BackgroundService(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
public async Task DoWorkAsync()
{
using var scope = _serviceScopeFactory.CreateScope();
var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
// Use scopedService here
}
}
ASP.NET Core supports three ways to inject dependencies:
This is the most common and recommended approach:
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IEmailService _emailService;
public UserService(IUserRepository userRepository, IEmailService emailService)
{
_userRepository = userRepository;
_emailService = emailService;
}
public async Task<User> GetUserByIdAsync(int id)
{
return await _userRepository.GetByIdAsync(id);
}
}
Advantages:
In minimal APIs, you can inject services directly as method parameters:
app.MapGet("/user/{id}", async (int id, IUserService userService) =>
{
var user = await userService.GetUserByIdAsync(id);
return user != null ? Results.Ok(user) : Results.NotFound();
});
While supported, property injection is generally not recommended as it makes dependencies implicit:
public class UserService : IUserService
{
[FromServices] // Required attribute in some scenarios
public IUserRepository UserRepository { get; set; }
}
You can register generic types:
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Usage
public class UserService
{
private readonly IRepository<User> _userRepository;
public UserService(IRepository<User> userRepository)
{
_userRepository = userRepository;
}
}
Register different implementations based on configuration:
if (builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<IEmailService, MockEmailService>();
}
else
{
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
}
// Or using configuration
var emailProvider = builder.Configuration["Email:Provider"];
if (emailProvider == "SendGrid")
{
builder.Services.AddScoped<IEmailService, SendGridEmailService>();
}
else
{
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
}
Implement the decorator pattern to add cross-cutting concerns:
// Register the concrete implementation
builder.Services.AddScoped<IUserService, UserService>();
// Register decorator
builder.Services.Decorate<IUserService, CachedUserService>();
builder.Services.Decorate<IUserService, LoggingUserService>();
// Note: Decorate is not built-in, but you can implement it or use a library
While you can use IServiceProvider directly, it's generally considered an anti-pattern:
// ❌ Avoid this
public class UserService
{
private readonly IServiceProvider _serviceProvider;
public UserService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void DoWork()
{
var emailService = _serviceProvider.GetRequiredService<IEmailService>();
// Use emailService
}
}
// ✅ Prefer constructor injection
public class UserService
{
private readonly IEmailService _emailService;
public UserService(IEmailService emailService)
{
_emailService = emailService;
}
}
One of the primary benefits of DI is improved testability. Here's how you can leverage it:
[Fact]
public async Task GetUserByIdAsync_ReturnsUser_WhenUserExists()
{
// Arrange
var mockRepository = new Mock<IUserRepository>();
var expectedUser = new User { Id = 1, Name = "John Doe" };
mockRepository.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(expectedUser);
var userService = new UserService(mockRepository.Object, Mock.Of<IEmailService>());
// Act
var result = await userService.GetUserByIdAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedUser.Name, result.Name);
mockRepository.Verify(r => r.GetByIdAsync(1), Times.Once);
}
public class UserControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public UserControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetUser_ReturnsOk_WhenUserExists()
{
// Override service registration for testing
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddScoped<IUserRepository, InMemoryUserRepository>();
});
}).CreateClient();
var response = await client.GetAsync("/user/1");
response.EnsureSuccessStatusCode();
}
}
Prefer Interface-Based Design: Always use interfaces for services that might have multiple implementations or need to be mocked.
Use Appropriate Lifetimes: Choose the right lifetime based on the service's purpose. Most services should be scoped.
Avoid Service Locator: Don't inject IServiceProvider unless absolutely necessary. Prefer constructor injection.
Don't Store Scoped Services in Singletons: Use IServiceScopeFactory if you need to create scoped services from a singleton.
Validate Dependencies: Use null-checking in constructors to fail fast if dependencies are missing:
public UserService(IUserRepository userRepository)
{
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
}
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddUserServices(this IServiceCollection services)
{
services.AddScoped<IUserService, UserService>();
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
}
// In Program.cs
builder.Services.AddUserServices();
Wrong Service Lifetime: Using singleton for services that should be scoped (like DbContext) can lead to data corruption or concurrency issues.
Capturing Scoped Services: Storing scoped services in singleton fields can cause memory leaks and incorrect behavior.
Over-injection: Having too many dependencies in a constructor (more than 3-4) might indicate that the class is doing too much.
Not Disposing Services: If your service implements IDisposable, the container will dispose it automatically when the scope ends. Don't manually dispose injected services.
Dependency Injection is a powerful pattern that, when used correctly, significantly improves the quality of your ASP.NET Core applications. It promotes:
By understanding service lifetimes, choosing appropriate registration methods, and following best practices, you can build robust, scalable, and maintainable applications. The built-in DI container in ASP.NET Core is sufficient for most scenarios, but don't hesitate to explore third-party containers if you need more advanced features.
Remember: DI is not just a framework feature—it's a design philosophy that encourages better software architecture and cleaner code.