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

17 KiB

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:

// Command handler (default: Scoped)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();

// Behind the scenes:
builder.Services.AddScoped<ICommandHandler<CreateUserCommand, int>, CreateUserCommandHandler>();

Registration with Validator

// 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

Default lifetime for all handlers:

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:

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:

// 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:

// Validators are Transient by default
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
    }
}

Singleton

One instance for application lifetime:

// 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:

// ❌ 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

// ❌ 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

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

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

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

// 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

// 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

// 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

// 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:

// 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

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:

[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:

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

// ❌ Bad
public class SingletonHandler
{
    private readonly ApplicationDbContext _context; // Scoped - ERROR!
}

Solution: Use correct lifetime

// ✅ 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

// ✅ 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?

See Also