Dependency Injection Deep Dive
Overview
Dependency Injection (DI) is a fundamental design pattern in ASP.NET Core, built into the framework's core. It promotes loose coupling, testability, and maintainability.
Service Lifetimes
ASP.NET Core supports three service lifetimes:
1. Transient
New instance created every time the service is requested.
builder.Services.AddTransient<IEmailService, EmailService>();
When to use:
- Lightweight, stateless services
- Services with no shared state
- Short-lived operations
Example:
public interface IEmailService
{
Task SendEmailAsync(string to, string subject, string body);
}
public class EmailService : IEmailService
{
public async Task SendEmailAsync(string to, string subject, string body)
{
// Each call gets a new instance
await Task.Delay(100); // Simulate sending
}
}
2. Scoped
One instance per HTTP request (or scope).
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<DbContext, ApplicationDbContext>();
When to use:
- Database contexts (EF Core)
- Services that should share state within a request
- Unit of Work pattern
Example:
public class ProductService : IProductService
{
private readonly ApplicationDbContext _context;
public ProductService(ApplicationDbContext context)
{
_context = context; // Same context instance for entire request
}
public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
}
Important: Scoped services are disposed at the end of the request.
3. Singleton
One instance for the entire application lifetime.
builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
When to use:
- Configuration objects
- Caching services
- Logging
- Thread-safe shared state
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)
{
_cache.TryGetValue(key, out var value);
return (T)value;
}
}
⚠️ Warning: Singletons must be thread-safe!
Lifetime Comparison Table
| Lifetime | Created | Disposed | Use Case |
|---|---|---|---|
| Transient | Every request | After use | Lightweight, stateless |
| Scoped | Once per HTTP request | End of request | DbContext, request data |
| Singleton | Once per app | App shutdown | Config, cache, logging |
Common Pitfalls
❌ Captive Dependencies
Problem: Longer-lived service depends on shorter-lived service
// WRONG! Singleton depends on Scoped
public class SingletonService
{
private readonly ScopedService _scoped; // BAD!
public SingletonService(ScopedService scoped)
{
_scoped = scoped; // This scoped instance will never be disposed!
}
}
Solution: Use IServiceProvider or refactor to proper lifetime
// CORRECT
public class SingletonService
{
private readonly IServiceProvider _serviceProvider;
public SingletonService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void DoWork()
{
using var scope = _serviceProvider.CreateScope();
var scopedService = scope.ServiceProvider.GetRequiredService<ScopedService>();
// Use scoped service
}
}
Registration Patterns
Basic Registration
// Interface → Implementation
builder.Services.AddScoped<IProductService, ProductService>();
// Concrete type only
builder.Services.AddScoped<ProductService>();
// Factory pattern
builder.Services.AddScoped<IProductService>(sp =>
{
var logger = sp.GetRequiredService<ILogger<ProductService>>();
return new ProductService(logger);
});
Multiple Implementations
// Register multiple implementations
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
// Retrieve all
public class NotificationDispatcher
{
private readonly IEnumerable<INotificationService> _services;
public NotificationDispatcher(IEnumerable<INotificationService> services)
{
_services = services;
}
public async Task NotifyAllAsync(string message)
{
foreach (var service in _services)
{
await service.SendAsync(message);
}
}
}
TryAdd Methods
// Only registers if not already registered
builder.Services.TryAddScoped<IProductService, ProductService>();
// Useful in libraries to provide default implementations
Replace and Remove
// Replace existing registration
builder.Services.Replace(ServiceDescriptor.Scoped<IProductService, NewProductService>());
// Remove registration
builder.Services.RemoveAll<IProductService>();
Constructor Injection
Preferred method - framework automatically resolves dependencies:
public class ProductController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductController> _logger;
public ProductController(
IProductService productService,
ILogger<ProductController> logger)
{
_productService = productService;
_logger = logger;
}
}
Method Injection (Minimal APIs)
app.MapGet("/api/products", async (IProductService productService) =>
{
var products = await productService.GetAllAsync();
return Results.Ok(products);
});
Property Injection
Not supported by default - use [FromServices] in controllers:
[ApiController]
public class ProductController : ControllerBase
{
[FromServices]
public IProductService ProductService { get; set; }
}
Service Locator Anti-Pattern
❌ Avoid using IServiceProvider directly in business logic:
// ANTI-PATTERN
public class ProductService
{
private readonly IServiceProvider _serviceProvider;
public ProductService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void DoSomething()
{
var dependency = _serviceProvider.GetService<IDependency>(); // BAD!
}
}
✅ Use constructor injection instead:
// GOOD PATTERN
public class ProductService
{
private readonly IDependency _dependency;
public ProductService(IDependency dependency)
{
_dependency = dependency;
}
public void DoSomething()
{
_dependency.Execute(); // GOOD!
}
}
Real-World Example
// Service Registration
public static class ServiceRegistration
{
public static IServiceCollection AddApplicationServices(
this IServiceCollection services)
{
// Scoped - per request
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IOrderService, OrderService>();
// Singleton - shared state
services.AddSingleton<ICacheService, CacheService>();
// Transient - stateless
services.AddTransient<IEmailService, EmailService>();
services.AddTransient<IPdfGenerator, PdfGenerator>();
return services;
}
}
// Usage in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationServices();
Interview Questions
Q: Explain the three DI lifetimes and when to use each. A:
- Transient: New instance every time, used for lightweight stateless services
- Scoped: One instance per HTTP request, used for DbContext and request-specific data
- Singleton: One instance for app lifetime, used for configuration, caching, must be thread-safe
Q: What is a captive dependency and why is it a problem? A: When a longer-lived service (Singleton) holds a reference to a shorter-lived service (Scoped/Transient), preventing proper disposal and potentially causing memory leaks or stale data.
Q: Why is IServiceProvider considered an anti-pattern? A: It hides dependencies, makes testing harder, and defeats the purpose of dependency injection. Constructor injection makes dependencies explicit and testable.