Introduction
Dependency Injection (DI) is one of the most foundational patterns in modern .NET development. Since ASP.NET Core launched, DI has been a first-class citizen of the framework — no external containers required for most applications.
With .NET 9, the DI container has evolved even further:
Source-generated DI for faster startup & AOT
Keyed services for multiple implementations
Improved scope validation to avoid runtime bugs
Better async disposal support
This article explains the key DI concepts every .NET developer should master, along with practical examples that reflect real enterprise applications.
Why Dependency Injection Matters
| Benefit | Explanation |
|---|
| Loose Coupling | Swap implementations without changing consumers |
| Testability | Replace services with mocks for clean unit & integration tests |
| Separation of Concerns | Each component has a single responsibility |
| Maintainability | Smaller, modular codebase |
| Performance & Flexibility | DI container lifecycle controls object lifetimes |
Service Lifecycles
| Lifetime | Behavior | Example Use |
|---|
| Transient | New instance every request | Lightweight stateless logic |
| Scoped | One instance per request | DbContext, user context, UoW |
| Singleton | Single instance for app lifetime | Caching, config, stateless services |
Example registration
builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddSingleton<IAppClock, SystemClock>();
Constructor Injection (Recommended)
public class OrderService(IOrderRepository repo, ILogger<OrderService> log)
{
public async Task<Order> GetAsync(int id)
{
log.LogInformation("Fetching order {Id}", id);
return await repo.GetAsync(id);
}
}
Why constructor injection wins
Explicit dependencies
Immutable services
Startup validation
Best fit for testability
Avoid Property Injection
ASP.NET Core DI does not support property injection by default, and required dependencies should never be optional anyway.
// ❌ Avoid unless truly optional
public ILogger<ProductService>? Logger { get; set; }
Options Pattern
builder.Services.Configure<ApiSettings>(builder.Configuration.GetSection("ExternalApi"));
public class ApiClient(IOptions<ApiSettings> options, HttpClient http)
{
http.BaseAddress = new Uri(options.Value.BaseUrl);
}
| Interface | Lifetime | Use-case |
|---|
| IOptions<T> | Singleton | Static config |
| IOptionsSnapshot<T> | Scoped | Per-request config |
| IOptionsMonitor<T> | Singleton w/callback | Live reload scenarios |
Avoid Captive Dependencies
Rule: Don't inject scoped services into singletons.
❌ Wrong
builder.Services.AddSingleton<WorkerService>();
builder.Services.AddScoped<AppDbContext>(); // Will cause captive dependency issues
✅ Correct: Use IServiceScopeFactory
public class Worker(ILogger<Worker> log, IServiceScopeFactory scopeFactory)
{
protected async Task Execute()
{
await using var scope = scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
await repo.ProcessAsync();
}
}
Background Services & DI
When using BackgroundService, always create a scope:
public class OrderWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken token)
{
while(!token.IsCancellationRequested)
{
await using var scope = scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
await repo.SyncOrdersAsync();
await Task.Delay(30000, token);
}
}
}
Async Disposal
Use IAsyncDisposable when your service owns async disposable resources:
public class LogWriter : IAsyncDisposable
{
private readonly StreamWriter _writer = new("log.txt", append: true);
public async ValueTask DisposeAsync()
{
await _writer.DisposeAsync();
}
}
Decorators for Cross-Cutting Concerns
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderServiceDecorator>();
Logging, caching, validation → all great decorator use cases.
.NET 9 DI Enhancements
| Feature | Purpose |
|---|
| Source-Generated DI | Faster startup / AOT-ready |
| Keyed Services | Multiple implementations per contract |
| Lifetime Validation | Catch DI mistakes early |
| Better Disposal | Async cleanup by default |
Testing with DI
Override services in integration tests:
factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped<IEmailSender, FakeEmailSender>();
});
});
ASP.NET Core DI has matured into a fast, capable, production-grade container.
To write clean and efficient .NET applications:
✔ Prefer constructor injection
✔ Understand lifetimes (avoid captive deps)
✔ Use scopes in background workers
✔ Implement async disposal when needed
✔ Leverage Options & TryAdd
✔ Use source-generated DI & keyed services when appropriate