
Unlock Dependency Injection and IoC in .NET ☕
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:
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:
- SINGLETON LIFETIME
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>();
- SCOPED LIFETIME
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>();
- TRANSIENT LIFETIME
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
- Loose Coupling: High-level modules shouldn't depend on low-level modules; both should depend on abstractions
- Testability: DI makes unit testing easier by allowing mock injection
- Flexibility: Easy to swap implementations without changing dependent code
- Service Lifetimes: Choose appropriate lifetimes (Singleton, Scoped, Transient) based on your needs
- Avoid Captive Dependencies: Don't let long-lived services hold onto short-lived dependencies
- Keyed Services: Use keyed services when you need multiple implementations of the same interface
- 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.