Dependency Injection (DI) and Inversion of Control (IoC) are core principles in modern C# development for creating code that's flexible, testable, and easy to maintain.

Think of it like a coffee machine that needs water, but doesn't care who or what supplies it. The coffee machine (a high-level class) simply knows it needs an IWaterProvider (an abstraction) to function, but it doesn't need to know if the water is coming from a carafe or a faucet.

This decoupling is the essence of DI and IoC.

The Coffee Machine Analogy: Understanding DI and IoC

Imagine you're designing a smart coffee machine.

The naive approach would be to hardcode the water source:

// Bad: Tightly coupled design
public class CoffeeMachine
{
    private Carafe _carafe;

    public CoffeeMachine()
    {
        _carafe = new Carafe(); // Hardcoded dependency
    }

    public string BrewCoffee()
    {
        var water = _carafe.GetWater();
        return $"Brewing coffee with {water}";
    }
}

This design has problems:

  • Tight coupling: CoffeeMachine is hardcoded to use a Carafe
  • Hard to test: Can't easily mock the water source
  • Inflexible: Can't switch to a different water source without modifying code
  • Violates SOLID principles: Specifically Dependency Inversion Principle

How It's Done: The Core Concepts

The process of DI breaks down into three main parts:

DI Core Concepts Diagram

1. Abstraction (The Interface)

First, you define a contract with an interface. The interface specifies what a service can do. For our coffee machine analogy, we'd have an IWaterProvider interface with a GetWater() method.

The abstraction - defines what water providers can do

public interface IWaterProvider
{
    string GetWater();
    bool IsWaterAvailable();
    int GetWaterTemperature();
}

2. Low-Level (The Details)

Next, you create concrete classes that implement the interface. These are the "details" of the application, like a Carafe class that implements the IWaterProvider interface.

Low-level implementation #1:

public class Carafe : IWaterProvider
{
    private bool _isEmpty = false;

    public string GetWater()
    {
        if (_isEmpty)
            throw new InvalidOperationException("Carafe is empty!");
        
        _isEmpty = true;
        return "filtered water from carafe";
    }

    public bool IsWaterAvailable() => !_isEmpty;
    public int GetWaterTemperature() => 20; // Room temperature
}

Low-level implementation #2:

public class Faucet : IWaterProvider
{
    public string GetWater() => "tap water from faucet";
    public bool IsWaterAvailable() => true; // Always available
    public int GetWaterTemperature() => 15; // Cold tap water
}

Low-level implementation #3 - Premium water source:

public class PremiumWaterDispenser : IWaterProvider
{
    private readonly int _temperature;

    public PremiumWaterDispenser(int temperature = 25)
    {
        _temperature = temperature;
    }

    public string GetWater() => "premium spring water";
    public bool IsWaterAvailable() => true;
    public int GetWaterTemperature() => _temperature;
}

3. High-Level (The Orchestrator)

The high-level class, such as the CoffeeMachine, doesn't create a Carafe directly.

Instead, it asks for an IWaterProvider in its constructor.

This allows the high-level class to use any class that implements the interface, without being tightly coupled to a specific implementation.

The High-level class depends on abstraction, not concretions.

public class CoffeeMachine
{
    private readonly IWaterProvider _waterProvider;
    private readonly ILogger<CoffeeMachine> _logger;

    // Constructor injection - the DI container will provide dependencies
    public CoffeeMachine(IWaterProvider waterProvider, ILogger<CoffeeMachine> logger)
    {
        _waterProvider = waterProvider ?? throw new ArgumentNullException(nameof(waterProvider));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public string BrewCoffee()
    {
        _logger.LogInformation("Starting coffee brewing process");

        if (!_waterProvider.IsWaterAvailable())
        {
            _logger.LogWarning("No water available for brewing");
            return "Cannot brew coffee: No water available";
        }

        var waterTemp = _waterProvider.GetWaterTemperature();
        if (waterTemp < 18)
        {
            _logger.LogWarning($"Water temperature too cold: {waterTemp}°C");
        }

        var water = _waterProvider.GetWater();
        var result = $"☕ Delicious coffee brewed with {water} at {waterTemp}°C";
        
        _logger.LogInformation("Coffee brewing completed successfully");
        return result;
    }

    public string GetStatus()
    {
        var waterAvailable = _waterProvider.IsWaterAvailable();
        var waterTemp = _waterProvider.GetWaterTemperature();
        
        return $"Coffee Machine Status:\n" +
               $"- Water Available: {waterAvailable}\n" +
               $"- Water Temperature: {waterTemp}°C\n" +
               $"- Ready to Brew: {waterAvailable && waterTemp >= 10}";
    }
}

Dependency Registration and Service Lifetimes

To actually "inject" these dependencies, you use a DI container where you register your services. The three main lifetimes are Singleton, Scoped, and Transient.

Service Lifetime Examples

Let's explore each lifetime with practical examples:

  1. SINGLETON LIFETIME

Singleton

A single instance of the dependency is created and shared across the entire application Like using the same single carafe to fill the coffee machine every day of its life

builder.Services.AddSingleton<IWaterProvider, Carafe>();
  1. SCOPED LIFETIME

Singleton

A new instance is created once per scope (HTTP request in web apps). Like deciding to use the carafe today, but tomorrow you might use a bottle. A scope can be thought of as a unit of work or a session.

builder.Services.AddScoped<ICoffeeMachineService, CoffeeMachineService>();
  1. TRANSIENT LIFETIME

Singleton

A new instance is created each time it is requested With a transient approach, a new water provider is used every time you fill the water tank

builder.Services.AddTransient<ICoffeeOrderProcessor, CoffeeOrderProcessor>();

Captive Dependencies: The Hidden Pitfall

A common mistake is a "captive dependency," which is a dependency with an incorrectly configured lifetime.

This happens when a service with a longer lifetime depends on a service that has a shorter lifetime.

A service should never depend on a service that has a shorter lifetime than its own.

Transient Scoped Singleton
Transient
Scoped
Singleton

Problematic Example

// ❌ BAD: Singleton depends on Scoped service
builder.Services.AddSingleton<ICoffeeMachineManager, CoffeeMachineManager>(); // Long lifetime
builder.Services.AddScoped<ICoffeeOrderService, CoffeeOrderService>();       // Shorter lifetime

public class CoffeeMachineManager : ICoffeeMachineManager
{
    private readonly ICoffeeOrderService _orderService; // PROBLEM!

    // This creates a captive dependency - the singleton will hold onto
    // the first scoped instance it receives, preventing proper disposal
    public CoffeeMachineManager(ICoffeeOrderService orderService)
    {
        _orderService = orderService; // This scoped service becomes "captive"
    }
}

Correct Approaches

// ✅ GOOD: Use IServiceProvider to resolve scoped services when needed
public class CoffeeMachineManager : ICoffeeMachineManager
{
    private readonly IServiceProvider _serviceProvider;

    public CoffeeMachineManager(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task ProcessOrdersAsync()
    {
        // Create a scope and resolve the scoped service within it
        using var scope = _serviceProvider.CreateScope();
        var orderService = scope.ServiceProvider.GetRequiredService<ICoffeeOrderService>();
        
        // Use the service within the scope
        var items = orderService.GetCartItems();
        // Process items...
    }
}

// ✅ BETTER: Use IServiceScopeFactory for cleaner code
public class CoffeeMachineManager : ICoffeeMachineManager
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CoffeeMachineManager(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task ProcessOrdersAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var orderService = scope.ServiceProvider.GetRequiredService<ICoffeeOrderService>();
        
        var items = orderService.GetCartItems();
        // Process items...
    }
}

Keyed Services in .NET 8+

What if you need to use different implementations of the same interface for different purposes? For example, using a Faucet to rinse the filter and a Carafe to fill the water tank?

.NET 8 introduces keyed services to address this. Keyed services manage DI by associating them with keys for registration and retrieval.

You register a service with a specific key using methods like AddKeyedSingleton.

You then access the service using the [FromKeyedServices] attribute with the corresponding key.

Practical Keyed Services Example

// Register multiple implementations with different keys
builder.Services.AddKeyedSingleton<IWaterProvider, Carafe>("brewing");
builder.Services.AddKeyedSingleton<IWaterProvider, Faucet>("cleaning");
builder.Services.AddKeyedSingleton<IWaterProvider, PremiumWaterDispenser>("premium");

// Advanced coffee machine that uses different water sources for different purposes
public class AdvancedCoffeeMachine
{
    private readonly IWaterProvider _brewingWater;
    private readonly IWaterProvider _cleaningWater;
    private readonly IWaterProvider _premiumWater;
    private readonly ILogger<AdvancedCoffeeMachine> _logger;

    public AdvancedCoffeeMachine(
        [FromKeyedServices("brewing")] IWaterProvider brewingWater,
        [FromKeyedServices("cleaning")] IWaterProvider cleaningWater,
        [FromKeyedServices("premium")] IWaterProvider premiumWater,
        ILogger<AdvancedCoffeeMachine> logger)
    {
        _brewingWater = brewingWater;
        _cleaningWater = cleaningWater;
        _premiumWater = premiumWater;
        _logger = logger;
    }

    public string BrewRegularCoffee()
    {
        _logger.LogInformation("Brewing regular coffee");
        var water = _brewingWater.GetWater();
        return $"☕ Regular coffee with {water}";
    }

    public string BrewPremiumCoffee()
    {
        _logger.LogInformation("Brewing premium coffee");
        var water = _premiumWater.GetWater();
        return $"☕ Premium coffee with {water}";
    }

    public string CleanMachine()
    {
        _logger.LogInformation("Cleaning coffee machine");
        var water = _cleaningWater.GetWater();
        return $"🧽 Machine cleaned with {water}";
    }

    public string GetDetailedStatus()
    {
        return $"Advanced Coffee Machine Status:\n" +
               $"- Brewing Water: {_brewingWater.GetWaterTemperature()}°C, Available: {_brewingWater.IsWaterAvailable()}\n" +
               $"- Cleaning Water: {_cleaningWater.GetWaterTemperature()}°C, Available: {_cleaningWater.IsWaterAvailable()}\n" +
               $"- Premium Water: {_premiumWater.GetWaterTemperature()}°C, Available: {_premiumWater.IsWaterAvailable()}";
    }
}

Conclusion

Dependency Injection and Inversion of Control are fundamental concepts that transform how we write C# applications. By following the coffee machine analogy:

  • Abstractions (interfaces) define contracts
  • Low-level implementations provide specific functionality
  • High-level classes depend on abstractions, not concretions
  • DI containers wire everything together with appropriate lifetimes

This gives you granular control over which implementation of an interface is used in different parts of your application, all while maintaining DI principles.

Key Takeaways

  1. Loose Coupling: High-level modules shouldn't depend on low-level modules; both should depend on abstractions
  2. Testability: DI makes unit testing easier by allowing mock injection
  3. Flexibility: Easy to swap implementations without changing dependent code
  4. Service Lifetimes: Choose appropriate lifetimes (Singleton, Scoped, Transient) based on your needs
  5. Avoid Captive Dependencies: Don't let long-lived services hold onto short-lived dependencies
  6. Keyed Services: Use keyed services when you need multiple implementations of the same interface
  7. Constructor Injection: Prefer constructor injection over property injection for better explicitness

Whether you're building Blazor applications, APIs, or desktop applications, mastering DI and IoC will make your code more maintainable, testable, and robust. The patterns shown here scale from simple applications to complex enterprise systems, making them essential tools in any .NET developer's toolkit.