Section 5 of 6

Constructor Injection

🎯 What You'll Learn

  • Why constructor injection is preferred
  • Best practices for constructor injection
  • Handling multiple dependencies
  • Optional dependencies
  • Avoiding constructor over-injection
  • Null checking and validation
  • Primary constructors (C# 12)
  • Common patterns for InvenTrack

Why Constructor Injection?

Constructor injection is the recommended way to inject dependencies because:

  • Explicit dependencies: Clear what a class needs
  • Immutability: Dependencies can't change after construction
  • Testability: Easy to provide mocks in tests
  • Fail-fast: Missing dependencies cause immediate errors
  • No nulls: Dependencies are guaranteed to exist

Basic Constructor Injection

Simple Example C#
public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly ILogger<ProductService> _logger;

    public ProductService(
        IProductRepository repository,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        _logger.LogInformation("Getting product with ID {Id}", id);
        return await _repository.GetByIdAsync(id);
    }
}
💡 Readonly Fields

Always use readonly for injected dependencies. This ensures they can't be accidentally reassigned after construction.

Multiple Dependencies

It's common to inject multiple dependencies:

Multiple Dependencies C#
public class OrderService : IOrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        ICustomerRepository customerRepository,
        IEmailService emailService,
        ILogger<OrderService> logger)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _customerRepository = customerRepository;
        _emailService = emailService;
        _logger = logger;
    }
}
⚠️ Too Many Dependencies?

If you have more than 5-6 dependencies, your class might be doing too much. Consider splitting it into smaller, focused services (Single Responsibility Principle).

Optional Dependencies

Sometimes a dependency is optional. You can handle this in two ways:

Method 1: Nullable Parameter (Not Recommended)

❌ Nullable Injection C#
public class ProductService
{
    private readonly ICacheService? _cache;

    public ProductService(ICacheService? cache = null)
    {
        _cache = cache;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        if (_cache != null)
        {
            var cached = await _cache.GetAsync<Product>($"product:{id}");
            if (cached != null) return cached;
        }
        
        // Get from database...
    }
}

Problem: Null checks everywhere, harder to test.

Method 2: Null Object Pattern (Recommended)

✅ Null Object Pattern C#
// Null implementation that does nothing
public class NullCacheService : ICacheService
{
    public Task<T?> GetAsync<T>(string key) => Task.FromResult<T?>(default);
    public Task SetAsync<T>(string key, T value) => Task.CompletedTask;
}

// Register the null implementation when caching is disabled
if (builder.Configuration.GetValue<bool>("EnableCaching"))
{
    builder.Services.AddSingleton<ICacheService, RedisCacheService>();
}
else
{
    builder.Services.AddSingleton<ICacheService, NullCacheService>();
}

// Now ProductService always has a cache, no null checks needed!
public class ProductService
{
    private readonly ICacheService _cache; // No nullable!

    public ProductService(ICacheService cache)
    {
        _cache = cache;
    }
}

Null Checking and Validation

Traditional Null Checks

Manual Validation C#
public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(repository));
    }
}

C# 11: Required Members (Not for DI)

❌ Don't Use Required for DI C#
// This doesn't work with DI container!
public class ProductService
{
    public required IProductRepository Repository { get; set; }
}

Modern Approach: Trust the Container

✅ Simple and Clean C#
public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository; // No null check needed!
    }
}
ℹ️ Why No Null Checks?

The DI container guarantees that all dependencies are resolved. If a dependency is missing, the app fails at startup (fail-fast), not at runtime. Null checks are redundant.

Primary Constructors (C# 12)

C# 12 introduced primary constructors for classes, which can simplify DI:

Traditional Constructor

Before C# 12 C#
public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly ILogger<ProductService> _logger;

    public ProductService(
        IProductRepository repository,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        _logger.LogInformation("Getting product {Id}", id);
        return await _repository.GetByIdAsync(id);
    }
}

Primary Constructor

C# 12 Primary Constructor C#
public class ProductService(
    IProductRepository repository,
    ILogger<ProductService> logger) : IProductService
{
    public async Task<Product?> GetByIdAsync(int id)
    {
        logger.LogInformation("Getting product {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

Benefits: Less boilerplate, no field declarations or assignments needed!

⚠️ Primary Constructor Gotcha

Primary constructor parameters are not readonly and can be captured by lambdas/closures, which may cause unexpected behavior. Use with caution in complex scenarios.

Avoiding Constructor Over-Injection

If your constructor has too many parameters, consider these solutions:

1. Split the Class

Before: Too Many Responsibilities C#
public class OrderService
{
    // 8 dependencies - too many!
    public OrderService(
        IOrderRepository orderRepo,
        IProductRepository productRepo,
        ICustomerRepository customerRepo,
        IInventoryService inventoryService,
        IEmailService emailService,
        IPaymentService paymentService,
        IShippingService shippingService,
        ILogger<OrderService> logger) { }
}
After: Split into Focused Services C#
public class OrderCreationService
{
    public OrderCreationService(
        IOrderRepository orderRepo,
        IInventoryService inventoryService,
        ILogger<OrderCreationService> logger) { }
}

public class OrderFulfillmentService
{
    public OrderFulfillmentService(
        IOrderRepository orderRepo,
        IPaymentService paymentService,
        IShippingService shippingService,
        IEmailService emailService,
        ILogger<OrderFulfillmentService> logger) { }
}

2. Use Facade Pattern

Facade to Group Related Services C#
public interface INotificationFacade
{
    Task SendOrderConfirmationAsync(Order order);
}

public class NotificationFacade : INotificationFacade
{
    private readonly IEmailService _emailService;
    private readonly ISmsService _smsService;
    private readonly IPushNotificationService _pushService;

    public NotificationFacade(
        IEmailService emailService,
        ISmsService smsService,
        IPushNotificationService pushService)
    {
        _emailService = emailService;
        _smsService = smsService;
        _pushService = pushService;
    }

    public async Task SendOrderConfirmationAsync(Order order)
    {
        await _emailService.SendAsync(order.CustomerEmail, "Order confirmed");
        await _smsService.SendAsync(order.CustomerPhone, "Order confirmed");
    }
}

// Now OrderService only needs INotificationFacade
public class OrderService
{
    public OrderService(
        IOrderRepository orderRepo,
        INotificationFacade notifications) { }
}

Complete InvenTrack Examples

Controller with DI

ProductsController.cs C#
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        IProductService productService,
        ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetById(int id)
    {
        _logger.LogInformation("Getting product {Id}", id);
        
        var product = await _productService.GetByIdAsync(id);
        
        if (product == null)
            return NotFound();
        
        return Ok(product);
    }
}

Service with Multiple Dependencies

InventoryService.cs C#
public class InventoryService : IInventoryService
{
    private readonly IProductRepository _productRepository;
    private readonly IEmailService _emailService;
    private readonly ILogger<InventoryService> _logger;

    public InventoryService(
        IProductRepository productRepository,
        IEmailService emailService,
        ILogger<InventoryService> logger)
    {
        _productRepository = productRepository;
        _emailService = emailService;
        _logger = logger;
    }

    public async Task CheckLowStockAsync()
    {
        var lowStockProducts = await _productRepository
            .GetLowStockProductsAsync(threshold: 10);

        foreach (var product in lowStockProducts)
        {
            _logger.LogWarning("Low stock: {Product}", product.Name);
            await _emailService.SendLowStockAlertAsync(product);
        }
    }
}

Key Takeaways

  • Constructor injection is the preferred DI method
  • Use readonly fields for injected dependencies
  • Dependencies are explicit and immutable
  • No null checks needed—trust the container
  • For optional dependencies, use the Null Object Pattern
  • Primary constructors (C# 12) reduce boilerplate
  • More than 5-6 dependencies? Consider splitting the class
  • Use Facade pattern to group related services
  • Fail-fast: Missing dependencies cause startup errors
  • Keep constructors simple—just assign dependencies
🎯 Next Steps

You now understand constructor injection best practices! In the final section, we'll explore Common DI Patterns—factory pattern, strategy pattern, chain of responsibility, and other advanced techniques for building InvenTrack.