dotnet-csharp-dependency-injection
Advanced Microsoft.Extensions.DependencyInjection patterns for .NET applications. Covers service lifetimes, keyed services (net8.0+), decoration, factory delegates, scope validation, and hosted service registration.
Scope
-
Service lifetimes (transient, scoped, singleton) and captive dependency detection
-
Keyed services (.NET 8+) and factory delegates
-
Decorator pattern and scope validation
-
Hosted service registration
Out of scope
-
Async/await patterns in BackgroundService -- see [skill:dotnet-csharp-async-patterns]
-
Options pattern binding and IOptions -- see [skill:dotnet-csharp-configuration]
-
SOLID/DRY design principles -- see [skill:dotnet-solid-principles]
Cross-references: [skill:dotnet-csharp-async-patterns] for BackgroundService async patterns, [skill:dotnet-csharp-configuration] for IOptions<T> binding.
Service Lifetimes
Lifetime Registration When to Use
Transient AddTransient<T>()
Lightweight, stateless services. New instance per injection.
Scoped AddScoped<T>()
Per-request state (EF Core DbContext , unit of work).
Singleton AddSingleton<T>()
Thread-safe, stateless, or shared state (caches, config).
Lifetime Mismatches (Captive Dependencies)
Never inject a shorter-lived service into a longer-lived one:
// WRONG -- scoped DbContext captured in singleton = same context for all requests builder.Services.AddSingleton<OrderService>(); // singleton builder.Services.AddScoped<AppDbContext>(); // scoped -- CAPTIVE!
// CORRECT -- use IServiceScopeFactory in singletons public sealed class OrderService(IServiceScopeFactory scopeFactory) { public async Task ProcessAsync(CancellationToken ct = default) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Orders.Where(o => o.IsPending).ToListAsync(ct); } }
Enable Scope Validation (Development)
var builder = WebApplication.CreateBuilder(args); // In Development, ValidateScopes is already true by default. // For non-web hosts: var host = Host.CreateDefaultBuilder(args) .UseDefaultServiceProvider(options => { options.ValidateScopes = true; options.ValidateOnBuild = true; // Validates all registrations at startup }) .Build();
Registration Patterns
Interface-Implementation Pair
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
Multiple Implementations
// Register multiple implementations builder.Services.AddScoped<INotifier, EmailNotifier>(); builder.Services.AddScoped<INotifier, SmsNotifier>(); builder.Services.AddScoped<INotifier, PushNotifier>();
// Inject all -- order matches registration order public sealed class CompositeNotifier(IEnumerable<INotifier> notifiers) { public async Task NotifyAsync(string message, CancellationToken ct = default) { foreach (var notifier in notifiers) { await notifier.NotifyAsync(message, ct); } } }
Factory Delegates
builder.Services.AddScoped<IOrderService>(sp => { var repo = sp.GetRequiredService<IOrderRepository>(); var logger = sp.GetRequiredService<ILogger<OrderService>>(); var options = sp.GetRequiredService<IOptions<OrderOptions>>(); return new OrderService(repo, logger, options.Value.MaxRetries); });
TryAdd for Library Registrations
Libraries should use TryAdd so applications can override:
// Library code -- won't overwrite app registrations builder.Services.TryAddScoped<IOrderRepository, DefaultOrderRepository>();
// Application code -- takes precedence if registered first builder.Services.AddScoped<IOrderRepository, CustomOrderRepository>();
Keyed Services (net8.0+)
Register and resolve services by a key, replacing the need for named service patterns.
// Registration builder.Services.AddKeyedScoped<ICache, RedisCache>("distributed"); builder.Services.AddKeyedScoped<ICache, MemoryCache>("local");
// Injection via attribute public sealed class OrderService( [FromKeyedServices("distributed")] ICache distributedCache, [FromKeyedServices("local")] ICache localCache) { public async Task<Order?> GetAsync(int id, CancellationToken ct = default) { // Check local cache first, then distributed return await localCache.GetAsync<Order>(id.ToString(), ct) ?? await distributedCache.GetAsync<Order>(id.ToString(), ct); } }
// Manual resolution var cache = sp.GetRequiredKeyedService<ICache>("distributed");
net8.0+ only. On earlier TFMs, use factory patterns or a dictionary-based approach.
Decoration Pattern
The built-in container does not natively support decoration. Use one of these approaches:
Manual Decoration
builder.Services.AddScoped<SqlOrderRepository>(); builder.Services.AddScoped<IOrderRepository>(sp => { var inner = sp.GetRequiredService<SqlOrderRepository>(); var logger = sp.GetRequiredService<ILogger<LoggingOrderRepository>>(); return new LoggingOrderRepository(inner, logger); });
public sealed class LoggingOrderRepository( IOrderRepository inner, ILogger<LoggingOrderRepository> logger) : IOrderRepository { public async Task<Order?> GetByIdAsync(int id, CancellationToken ct = default) { logger.LogInformation("Getting order {OrderId}", id); return await inner.GetByIdAsync(id, ct); } }
Scrutor Library (Popular Alternative)
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>(); builder.Services.Decorate<IOrderRepository, LoggingOrderRepository>(); builder.Services.Decorate<IOrderRepository, CachingOrderRepository>(); // Outer -> CachingOrderRepository -> LoggingOrderRepository -> SqlOrderRepository
Hosted Services and Background Workers
BackgroundService (Preferred)
public sealed class QueueProcessorWorker( IServiceScopeFactory scopeFactory, ILogger<QueueProcessorWorker> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Queue processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider
.GetRequiredService<IQueueProcessor>();
await processor.ProcessNextBatchAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Error processing queue batch");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
// Registration builder.Services.AddHostedService<QueueProcessorWorker>();
IHostedService (Startup/Shutdown Hooks)
public sealed class DatabaseMigrationService( IServiceScopeFactory scopeFactory, ILogger<DatabaseMigrationService> logger) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Database.MigrateAsync(cancellationToken); logger.LogInformation("Database migration completed"); }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
builder.Services.AddHostedService<DatabaseMigrationService>();
Key Rules for Hosted Services
-
Always use IServiceScopeFactory to create scopes -- hosted services are singletons
-
Never inject scoped services directly into hosted service constructors
-
Handle exceptions inside ExecuteAsync -- unhandled exceptions stop the host (net8.0+)
-
See [skill:dotnet-csharp-async-patterns] for async patterns in background workers
Organizing Registrations
Group related registrations into extension methods for clean Program.cs :
// ServiceCollectionExtensions.cs public static class ServiceCollectionExtensions { public static IServiceCollection AddOrderServices(this IServiceCollection services) { services.AddScoped<IOrderRepository, SqlOrderRepository>(); services.AddScoped<IOrderService, OrderService>(); services.AddHostedService<OrderProcessorWorker>(); return services; }
public static IServiceCollection AddNotificationServices(this IServiceCollection services)
{
services.AddScoped<INotifier, EmailNotifier>();
services.AddScoped<INotifier, SmsNotifier>();
return services;
}
}
// Program.cs builder.Services.AddOrderServices(); builder.Services.AddNotificationServices();
Testing with DI
[Fact] public async Task OrderService_UsesRepository() { // Arrange -- build a service provider for integration tests var services = new ServiceCollection(); services.AddScoped<IOrderRepository, InMemoryOrderRepository>(); services.AddScoped<IOrderService, OrderService>(); services.AddLogging();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IOrderService>();
// Act
var order = await service.GetByIdAsync(1);
// Assert
Assert.NotNull(order);
}
For unit tests, prefer direct constructor injection with mocks rather than building a full container.
References
-
Dependency injection in .NET
-
Keyed services in .NET 8
-
Background tasks with hosted services
-
Service lifetimes
-
.NET Framework Design Guidelines