Dependency Injection
Dependency Injection (DI) is a design pattern that implements Inversion of Control (IoC) for managing dependencies between objects. Rather than objects creating their own dependencies, they receive them from an external source. This promotes loose coupling, testability, and maintainable code.
Understanding Dependency Injection
The Restaurant Analogy
Imagine you're running a restaurant. Without DI, your chef would need to:
- Grow vegetables in a garden
- Raise chickens for eggs
- Mill flour for bread
- Manufacture cooking equipment
This is tightly coupled and impractical. With DI, you provide the chef with pre-sourced ingredients and equipment. The chef doesn't care where the ingredients come from, they just use what's provided to create dishes.
In code terms:
- Without DI: Classes create their own dependencies (the chef grows vegetables)
- With DI: Classes receive dependencies from outside (ingredients are provided)
Inversion of Control
Traditional programming flow: "I need a database connection, so I'll create one."
public class PlatformsController
{
private readonly AppDbContext _context;
public PlatformsController()
{
_context = new AppDbContext(); // ❌ Tightly coupled
}
}
Inversion of Control: "I need a database connection, someone else will provide it."
public class PlatformsController
{
private readonly AppDbContext _context;
public PlatformsController(AppDbContext context) // ✅ Dependency injected
{
_context = context;
}
}
Control is inverted: The class (in this case a controller) no longer controls how its dependencies are created, that responsibility is inverted to an external container, and the dependency is injected into the class via it's constructor.
The .NET DI Container
ASP.NET Core includes a built-in DI container (also called the service container) that manages object creation and lifetime. It's lightweight, capable, and sufficient for most applications.
The container:
- Resolves dependencies: Creates objects and their dependencies automatically
- Manages lifetimes: Controls when objects are created and disposed
- Handles dependency graphs: Resolves nested dependencies recursively
When your application starts, services are registered in the container. When a class requests a dependency, the container automatically provides an instance.
Request → Controller needs IRepository
→ Container creates/provides IRepository instance
→ Controller uses IRepository
Registering Services
Services are registered in Program.cs using extension methods on IServiceCollection. This tells the container what to provide when a dependency is requested.
Basic Registration
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddScoped<ICommandRepository, CommandRepository>();
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddTransient<IEmailService, EmailService>();
Registration Patterns
Interface to Implementation:
builder.Services.AddScoped<IPlatformRepository, PlatformRepository>();
// When someone requests IPlatformRepository, provide PlatformRepository
Concrete Types:
builder.Services.AddScoped<AppDbContext>();
// Register the concrete type directly
Factory Functions:
builder.Services.AddScoped<IApiClient>(provider =>
{
var config = provider.GetRequiredService<IConfiguration>();
var apiKey = config["ApiKeys:External"];
return new ApiClient(apiKey);
});
Service Lifetimes
The container manages three distinct lifetimes that control when instances are created and disposed.
Transient
Created every time they're requested.
builder.Services.AddTransient<IEmailService, EmailService>();
Characteristics:
- New instance per request
- Disposed after use
- Lightweight services with no state
- No shared data between requests
Use for:
- Stateless services
- Lightweight operations
- Services that shouldn't be shared
Example:
public class EmailService : IEmailService
{
public Task SendEmail(string to, string subject, string body)
{
// Send email logic
return Task.CompletedTask;
}
}
Scoped
Created once per HTTP request (or scope).
builder.Services.AddScoped<ICommandRepository, CommandRepository>();
builder.Services.AddScoped<AppDbContext>();
Characteristics:
- One instance per HTTP request
- Shared across the request pipeline
- Disposed at end of request
- Most common lifetime for web applications
Use for:
- Database contexts (EF Core)
- Repositories
- Unit of Work patterns
- Request-specific services
Example:
public class CommandRepository : ICommandRepository
{
private readonly AppDbContext _context;
public CommandRepository(AppDbContext context)
{
_context = context; // Same context throughout the request
}
}
Singleton
Created once for the application lifetime.
builder.Services.AddSingleton<ICacheService, CacheService>();
Characteristics:
- Single instance for entire application
- Created on first request (or at startup)
- Never disposed until application shuts down
- Shared across all requests and users
Use for:
- Configuration services
- Caching services
- Thread-safe stateless services
- Application-wide services
Example:
public class CacheService : ICacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();
public void Set(string key, object value) => _cache[key] = value;
public object? Get(string key) => _cache.TryGetValue(key, out var value) ? value : null;
}
Lifetime Comparison
| Lifetime | Created | Shared | Disposed | Use Case |
|---|---|---|---|---|
| Transient | Every injection | No | After use | Lightweight, stateless services |
| Scoped | Per HTTP request | Within request | End of request | Database contexts, repositories |
| Singleton | Once | Across entire app | Application shutdown | Caches, configuration, stateless utilities |
Lifetime Best Practices
Safe:
- Singleton → Singleton ✅
- Singleton → Transient ✅
- Scoped → Scoped ✅
- Scoped → Transient ✅
- Transient → Transient ✅
Unsafe (Captive Dependencies):
- Singleton → Scoped ❌ (Scoped service becomes singleton)
- Singleton → Transient ⚠️ (Transient service becomes singleton)
- Scoped → Singleton ✅ (But singleton must be thread-safe)
Injecting Dependencies
Dependencies can be injected via 2 methods when using the .NET DI Container
- Via a Constructor
- Via a Method
Other methods of injection (e.g. Property Injection) are possible via 3rd party DI containers.
Constructor Injection
public class PlatformsController : ControllerBase
{
private readonly IPlatformRepository _repository;
private readonly ILogger<PlatformsController> _logger;
private readonly IMapper _mapper;
// DI container injects all dependencies
public PlatformsController(
IPlatformRepository repository,
ILogger<PlatformsController> logger,
IMapper mapper)
{
_repository = repository;
_logger = logger;
_mapper = mapper;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<PlatformReadDto>>> GetPlatforms()
{
_logger.LogInformation("Retrieving all platforms");
var platforms = await _repository.GetAllAsync();
return Ok(_mapper.Map<IEnumerable<PlatformReadDto>>(platforms));
}
}
Method Injection
Dependencies can also be injected directly into method parameters. This is particularly useful in minimal APIs or when a dependency is only needed in one or two methods.
Minimal API Example:
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddScoped<IPlatformRepository, PlatformRepository>();
var app = builder.Build();
// Dependencies injected directly into the endpoint method
app.MapGet("/api/platforms", async (IPlatformRepository repository) =>
{
var platforms = await repository.GetAllAsync();
return Results.Ok(platforms);
});
app.MapGet("/api/platforms/{id}", async (int id, IPlatformRepository repository) =>
{
var platform = await repository.GetByIdAsync(id);
return platform is not null ? Results.Ok(platform) : Results.NotFound();
});
app.Run();
Controller Action Injection:
public class PlatformsController : ControllerBase
{
[HttpGet("special")]
public async Task<IActionResult> GetSpecialPlatforms(
[FromServices] IPlatformRepository repository,
[FromServices] ILogger<PlatformsController> logger)
{
logger.LogInformation("Fetching special platforms");
var platforms = await repository.GetSpecialAsync();
return Ok(platforms);
}
}
When to use Method Injection:
- Dependency needed in only one or two methods
- Minimal API endpoints
- Avoiding constructor clutter
- Optional dependencies for specific actions
Note: Constructor injection is still preferred for dependencies used across multiple methods.
Multiple Dependencies
The container resolves entire dependency graphs:
// Service A depends on B and C
public class ServiceA
{
public ServiceA(IServiceB serviceB, IServiceC serviceC) { }
}
// Service B depends on D
public class ServiceB : IServiceB
{
public ServiceB(IServiceD serviceD) { }
}
// When ServiceA is requested:
// Container creates ServiceD → ServiceB → ServiceC → ServiceA
Resolving Services Manually
Rarely needed, but possible using IServiceProvider:
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var repository = scope.ServiceProvider.GetRequiredService<IPlatformRepository>();
// Use repository
}
Benefits of Dependency Injection
Testability: Mock dependencies easily for unit testing
// Test with mock repository
var mockRepo = new Mock<IPlatformRepository>();
var controller = new PlatformsController(mockRepo.Object, ...);
- Loose Coupling: Classes depend on abstractions, not concrete implementations
- Maintainability: Change implementations without modifying consumers
- Flexibility: Swap implementations for different environments (e.g., mock services in development)
- Lifecycle Management: Container handles creation and disposal automatically
Dependency Injection is fundamental to modern .NET development. It's the backbone of ASP.NET Core's architecture and enables clean, testable, maintainable applications. Understanding how DI works in .NET is fundamental when working with the framework.
Further Reading: