dotnet-cqrs/docs/architecture/dependency-injection.md

662 lines
17 KiB
Markdown

# Dependency Injection
Understanding DI patterns, handler lifetime management, and service registration in Svrnty.CQRS.
## Overview
Svrnty.CQRS leverages ASP.NET Core's built-in dependency injection container for handler registration and resolution. Understanding handler lifetimes and DI patterns is crucial for building maintainable applications.
## Handler Registration
### Basic Registration
Handlers are registered with specific lifetimes:
```csharp
// Command handler (default: Scoped)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Behind the scenes:
builder.Services.AddScoped<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
```
### Registration with Validator
```csharp
// Command with validator
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
// Equivalent to:
builder.Services.AddScoped<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
```
## Handler Lifetimes
### Scoped (Default and Recommended)
**Default lifetime for all handlers:**
```csharp
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
// Handler is Scoped
```
**Characteristics:**
- One instance per HTTP request
- Disposed at end of request
- Can inject DbContext, scoped services
- **Recommended** for most handlers
**When to use:**
- ✅ Handlers that use EF Core DbContext
- ✅ Handlers that use scoped services
- ✅ Default choice (95% of cases)
**Example:**
```csharp
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly ApplicationDbContext _context; // Scoped
public CreateUserCommandHandler(ApplicationDbContext context)
{
_context = context;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
var user = new User { Name = command.Name };
_context.Users.Add(user);
await _context.SaveChangesAsync(cancellationToken);
return user.Id;
}
}
```
### Transient
**One instance per injection:**
```csharp
// Manually register as Transient
services.AddTransient<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
```
**Characteristics:**
- New instance each time it's injected
- Disposed after use
- Lightweight, no state
**When to use:**
- ✅ Stateless handlers
- ✅ Lightweight operations
- ✅ Validators (default)
**Example:**
```csharp
// Validators are Transient by default
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name).NotEmpty();
}
}
```
### Singleton
**One instance for application lifetime:**
```csharp
// Manually register as Singleton
services.AddSingleton<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();
```
**Characteristics:**
- Single instance shared across all requests
- Never disposed (until app shutdown)
- Must be thread-safe
- Cannot inject scoped services
**When to use:**
- ⚠️ Rarely used for handlers
- ⚠️ Only for stateless, thread-safe handlers
- ❌ Not recommended (can't inject DbContext)
**Example:**
```csharp
// ❌ Bad: Singleton handler with scoped dependency
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly ApplicationDbContext _context; // ERROR: Cannot inject scoped service
// This will throw an exception at runtime!
}
// ✅ Good: Singleton handler with singleton dependencies
public class CacheUserCommandHandler : ICommandHandler<CacheUserCommand>
{
private readonly IMemoryCache _cache; // Singleton - OK
public CacheUserCommandHandler(IMemoryCache cache)
{
_cache = cache;
}
}
```
## Service Lifetime Rules
### Rule 1: Service Can Inject Same or Longer Lifetime
```
Singleton can inject:
✅ Singleton
❌ Scoped
❌ Transient
Scoped can inject:
✅ Singleton
✅ Scoped
✅ Transient
Transient can inject:
✅ Singleton
✅ Scoped
✅ Transient
```
### Rule 2: Don't Capture Shorter Lifetimes
```csharp
// ❌ Bad: Singleton captures scoped
public class SingletonService
{
private readonly ApplicationDbContext _context; // Scoped
// ERROR: Captive dependency
}
// ✅ Good: Use IServiceProvider for scoped resolution
public class SingletonService
{
private readonly IServiceProvider _serviceProvider;
public void DoWork()
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Use context...
}
}
```
## Constructor Injection
### Basic Injection
```csharp
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<CreateUserCommandHandler> _logger;
public CreateUserCommandHandler(
IUserRepository userRepository,
ILogger<CreateUserCommandHandler> logger)
{
_userRepository = userRepository;
_logger = logger;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
_logger.LogInformation("Creating user: {Email}", command.Email);
return await _userRepository.AddAsync(new User { Name = command.Name });
}
}
```
### Multiple Dependencies
```csharp
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
private readonly IOrderRepository _orders;
private readonly IInventoryService _inventory;
private readonly IPaymentService _payment;
private readonly IEmailService _email;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
public PlaceOrderCommandHandler(
IOrderRepository orders,
IInventoryService inventory,
IPaymentService payment,
IEmailService email,
ILogger<PlaceOrderCommandHandler> logger)
{
_orders = orders;
_inventory = inventory;
_payment = payment;
_email = email;
_logger = logger;
}
public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken)
{
// Use all dependencies...
}
}
```
### Optional Dependencies
```csharp
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
private readonly IEmailService? _emailService; // Optional
public CreateUserCommandHandler(
IUserRepository userRepository,
IEmailService? emailService = null) // Optional parameter
{
_userRepository = userRepository;
_emailService = emailService;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
var userId = await _userRepository.AddAsync(new User { Name = command.Name });
// Send email if service available
if (_emailService != null)
{
await _emailService.SendWelcomeEmailAsync(command.Email);
}
return userId;
}
}
```
## Common DI Patterns
### Repository Pattern
```csharp
// Interface in Domain layer
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<int> AddAsync(User user, CancellationToken cancellationToken);
}
// Implementation in Infrastructure layer
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Users.FindAsync(new object[] { id }, cancellationToken);
}
public async Task<int> AddAsync(User user, CancellationToken cancellationToken)
{
_context.Users.Add(user);
await _context.SaveChangesAsync(cancellationToken);
return user.Id;
}
}
// Registration
builder.Services.AddScoped<IUserRepository, UserRepository>();
// Usage in handler
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IUserRepository _userRepository;
public CreateUserCommandHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
}
```
### Service Pattern
```csharp
// Service interface
public interface IEmailService
{
Task SendWelcomeEmailAsync(string email, CancellationToken cancellationToken = default);
}
// Implementation
public class SendGridEmailService : IEmailService
{
private readonly IConfiguration _configuration;
public SendGridEmailService(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task SendWelcomeEmailAsync(string email, CancellationToken cancellationToken)
{
// Send via SendGrid...
}
}
// Registration
builder.Services.AddScoped<IEmailService, SendGridEmailService>();
```
### Options Pattern
```csharp
// Options class
public class EmailOptions
{
public string ApiKey { get; set; } = string.Empty;
public string FromEmail { get; set; } = string.Empty;
}
// appsettings.json
{
"Email": {
"ApiKey": "your-api-key",
"FromEmail": "noreply@example.com"
}
}
// Registration
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
// Injection
public class SendGridEmailService : IEmailService
{
private readonly EmailOptions _options;
public SendGridEmailService(IOptions<EmailOptions> options)
{
_options = options.Value;
}
}
```
### Decorator Pattern
```csharp
// Base interface
public interface ICommandHandler<in TCommand, TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken);
}
// Decorator for logging
public class LoggingCommandHandlerDecorator<TCommand, TResult> : ICommandHandler<TCommand, TResult>
{
private readonly ICommandHandler<TCommand, TResult> _inner;
private readonly ILogger _logger;
public LoggingCommandHandlerDecorator(
ICommandHandler<TCommand, TResult> inner,
ILogger<LoggingCommandHandlerDecorator<TCommand, TResult>> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken)
{
_logger.LogInformation("Executing command: {CommandType}", typeof(TCommand).Name);
var result = await _inner.HandleAsync(command, cancellationToken);
_logger.LogInformation("Command executed successfully");
return result;
}
}
// Registration with Scrutor
builder.Services.Decorate(typeof(ICommandHandler<,>), typeof(LoggingCommandHandlerDecorator<,>));
```
## Registration Organization
### Extension Methods
Group related registrations:
```csharp
// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddUserFeatures(this IServiceCollection services)
{
// Commands
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
services.AddCommand<UpdateUserCommand, UpdateUserCommandHandler, UpdateUserCommandValidator>();
services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
// Queries
services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
services.AddQuery<ListUsersQuery, List<UserDto>, ListUsersQueryHandler>();
// Repositories
services.AddScoped<IUserRepository, UserRepository>();
return services;
}
public static IServiceCollection AddOrderFeatures(this IServiceCollection services)
{
// Commands
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler>();
services.AddCommand<CancelOrderCommand, CancelOrderCommandHandler>();
// Queries
services.AddQuery<GetOrderQuery, OrderDto, GetOrderQueryHandler>();
// Repositories
services.AddScoped<IOrderRepository, OrderRepository>();
return services;
}
}
// Usage in Program.cs
builder.Services.AddUserFeatures();
builder.Services.AddOrderFeatures();
```
### Module Pattern
```csharp
public interface IModule
{
void RegisterServices(IServiceCollection services, IConfiguration configuration);
}
public class UserModule : IModule
{
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
services.AddScoped<IUserRepository, UserRepository>();
}
}
// Auto-register all modules
var modules = typeof(Program).Assembly
.GetTypes()
.Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
.Select(Activator.CreateInstance)
.Cast<IModule>();
foreach (var module in modules)
{
module.RegisterServices(builder.Services, builder.Configuration);
}
```
## Testing with DI
### Unit Testing
Mock dependencies:
```csharp
[Fact]
public async Task CreateUser_WithValidData_ReturnsUserId()
{
// Arrange
var mockRepository = new Mock<IUserRepository>();
mockRepository
.Setup(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(123);
var handler = new CreateUserCommandHandler(mockRepository.Object);
var command = new CreateUserCommand { Name = "Alice", Email = "alice@example.com" };
// Act
var result = await handler.HandleAsync(command, CancellationToken.None);
// Assert
Assert.Equal(123, result);
mockRepository.Verify(r => r.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()), Times.Once);
}
```
### Integration Testing
Use WebApplicationFactory:
```csharp
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task CreateUser_WithValidData_Returns201()
{
// Arrange
var client = _factory.CreateClient();
var command = new { name = "Alice", email = "alice@example.com" };
// Act
var response = await client.PostAsJsonAsync("/api/command/createUser", command);
// Assert
response.EnsureSuccessStatusCode();
var userId = await response.Content.ReadFromJsonAsync<int>();
Assert.True(userId > 0);
}
}
```
## Best Practices
### ✅ DO
- Use Scoped lifetime for handlers (default)
- Inject interfaces, not concrete types
- Keep constructors simple (assignment only)
- Use readonly fields for dependencies
- Group registrations in extension methods
- Use Options pattern for configuration
- Test handlers in isolation
### ❌ DON'T
- Don't use Singleton for handlers (can't inject DbContext)
- Don't inject IServiceProvider into handlers (service locator anti-pattern)
- Don't perform logic in constructors
- Don't create circular dependencies
- Don't inject too many dependencies (5+ is a code smell)
- Don't mix lifetimes incorrectly (singleton injecting scoped)
## Common Issues
### Issue 1: Captive Dependency
**Problem:** Singleton captures scoped dependency
```csharp
// ❌ Bad
public class SingletonHandler
{
private readonly ApplicationDbContext _context; // Scoped - ERROR!
}
```
**Solution:** Use correct lifetime
```csharp
// ✅ Good
[ServiceLifetime(ServiceLifetime.Scoped)]
public class ScopedHandler
{
private readonly ApplicationDbContext _context; // OK now
}
```
### Issue 2: Disposed DbContext
**Problem:** DbContext disposed before use
**Cause:** Improper lifetime management
**Solution:** Ensure handler is Scoped and DbContext is Scoped
### Issue 3: Too Many Dependencies
**Problem:** Handler with 10+ constructor parameters
**Solution:** Refactor into domain services
```csharp
// ✅ Better: Extract domain service
public class OrderService
{
// Multiple dependencies here
}
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
private readonly OrderService _orderService; // Single dependency
}
```
## What's Next?
- **[Extensibility Points](extensibility-points.md)** - Framework customization
- **[Best Practices: Testing](../best-practices/testing.md)** - Testing strategies
## See Also
- [Modular Solution Structure](modular-solution-structure.md) - Organizing projects
- [Microsoft: Dependency Injection](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) - Official DI docs
- [Best Practices](../best-practices/README.md) - Production-ready patterns