Dependency Injection
Dependency injection patterns and service registration in .NET's built-in DI container
You are an expert in dependency injection for building loosely coupled, testable .NET applications.
## Key Points
- **Depend on abstractions** (`IOrderService`) not concrete types (`OrderService`) to enable testing and substitution.
- **Keep constructors simple** — only assign fields. Do not perform I/O, heavy computation, or call virtual methods in constructors.
- **Avoid the service locator pattern** — do not inject `IServiceProvider` and resolve services manually. Prefer constructor injection.
- **Validate options on startup** — use `ValidateOnStart()` so misconfiguration fails fast during application boot, not at first request.
- **Use `AddHttpClient`** for HTTP dependencies — it manages `HttpMessageHandler` lifetimes and avoids socket exhaustion.
- **Resolving scoped services from the root provider** — creating a scope is required outside of an HTTP request context (e.g., in a background service).
- **Registering the same interface multiple times unintentionally** — the last registration wins for single-instance resolution. Use `TryAdd*` methods to avoid overwriting.
## Quick Example
```csharp
// Register a generic interface with a generic implementation
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddTransient(typeof(IValidator<>), typeof(DataAnnotationsValidator<>));
// Now IRepository<Product>, IRepository<Order>, etc. all resolve automatically
```
```csharp
builder.Services.Scan(scan => scan
.FromAssemblyOf<IOrderService>()
.AddClasses(c => c.AssignableTo<IOrderService>())
.AsImplementedInterfaces()
.WithScopedLifetime());
```skilldb get csharp-dotnet-skills/Dependency InjectionFull skill: 238 linesDependency Injection — C#/.NET
You are an expert in dependency injection for building loosely coupled, testable .NET applications.
Core Philosophy
Overview
.NET ships with a built-in DI container (Microsoft.Extensions.DependencyInjection) that is the backbone of ASP.NET Core, Worker Services, and MAUI. It supports constructor injection, service lifetimes, keyed services, and open-generic registration. Understanding DI is fundamental to writing idiomatic .NET code.
Core Concepts
Service Lifetimes
var builder = WebApplication.CreateBuilder(args);
// Transient: new instance every time it is requested
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// Scoped: one instance per HTTP request (or per scope)
builder.Services.AddScoped<IOrderService, OrderService>();
// Singleton: one instance for the entire application lifetime
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
| Lifetime | Created When | Disposed When | Typical Use |
|---|---|---|---|
| Transient | Every resolution | End of scope | Lightweight, stateless logic |
| Scoped | First resolution in scope | End of scope | DbContext, per-request state |
| Singleton | First resolution | Application shutdown | Caches, configuration, HttpClient factories |
Constructor Injection
public class OrderService : IOrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailSender _email;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repo,
IEmailSender email,
ILogger<OrderService> logger)
{
_repo = repo;
_email = email;
_logger = logger;
}
public async Task<Order> PlaceOrderAsync(CreateOrderRequest request)
{
_logger.LogInformation("Placing order for {Customer}", request.CustomerId);
var order = await _repo.CreateAsync(request);
await _email.SendOrderConfirmationAsync(order);
return order;
}
}
Keyed Services (.NET 8+)
builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");
public class NotificationService(
[FromKeyedServices("email")] INotificationSender email,
[FromKeyedServices("sms")] INotificationSender sms)
{
public Task NotifyAsync(string message, string channel) => channel switch
{
"email" => email.SendAsync(message),
"sms" => sms.SendAsync(message),
_ => throw new ArgumentException($"Unknown channel: {channel}")
};
}
Implementation Patterns
Options Pattern for Configuration
public class SmtpOptions
{
public const string SectionName = "Smtp";
public required string Host { get; set; }
public int Port { get; set; } = 587;
public required string Username { get; set; }
public required string Password { get; set; }
}
// Registration
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// Injection
public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender
{
private readonly SmtpOptions _smtp = options.Value;
}
Factory Registration
// Register using a factory delegate when construction is non-trivial
builder.Services.AddScoped<IPaymentGateway>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
return env.IsDevelopment()
? new FakePaymentGateway()
: new StripePaymentGateway(config["Stripe:ApiKey"]!);
});
Decorator Pattern
public class CachedProductRepository : IProductRepository
{
private readonly IProductRepository _inner;
private readonly IMemoryCache _cache;
public CachedProductRepository(IProductRepository inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetByIdAsync(int id)
{
return await _cache.GetOrCreateAsync($"product:{id}",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await _inner.GetByIdAsync(id);
});
}
}
// Registration (manual decorator wiring)
builder.Services.AddScoped<ProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp =>
new CachedProductRepository(
sp.GetRequiredService<ProductRepository>(),
sp.GetRequiredService<IMemoryCache>()));
Open Generic Registration
// Register a generic interface with a generic implementation
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddTransient(typeof(IValidator<>), typeof(DataAnnotationsValidator<>));
// Now IRepository<Product>, IRepository<Order>, etc. all resolve automatically
Assembly Scanning with Scrutor
builder.Services.Scan(scan => scan
.FromAssemblyOf<IOrderService>()
.AddClasses(c => c.AssignableTo<IOrderService>())
.AsImplementedInterfaces()
.WithScopedLifetime());
Best Practices
- Depend on abstractions (
IOrderService) not concrete types (OrderService) to enable testing and substitution. - Keep constructors simple — only assign fields. Do not perform I/O, heavy computation, or call virtual methods in constructors.
- Avoid the service locator pattern — do not inject
IServiceProviderand resolve services manually. Prefer constructor injection. - Validate options on startup — use
ValidateOnStart()so misconfiguration fails fast during application boot, not at first request. - Use
AddHttpClientfor HTTP dependencies — it managesHttpMessageHandlerlifetimes and avoids socket exhaustion.
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0");
});
Common Pitfalls
- Captive dependency — a singleton holding a reference to a scoped or transient service keeps it alive for the entire application lifetime, bypassing its intended disposal. The DI container logs a warning for this; enable
ValidateScopesin development.
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});
- Resolving scoped services from the root provider — creating a scope is required outside of an HTTP request context (e.g., in a background service).
public class MyBackgroundService(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
await orderService.ProcessPendingAsync(ct);
}
}
- Registering the same interface multiple times unintentionally — the last registration wins for single-instance resolution. Use
TryAdd*methods to avoid overwriting. - Injecting
IOptions<T>whenIOptionsSnapshot<T>is needed —IOptions<T>reads configuration once at startup;IOptionsSnapshot<T>re-reads per scope, which is necessary for reloadable configuration.
Anti-Patterns
Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.
Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.
Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add csharp-dotnet-skills
Related Skills
Aspnet Minimal API
ASP.NET Minimal APIs for building lightweight HTTP endpoints in .NET
Async Patterns
Async/await patterns, Task-based concurrency, and cancellation in C#/.NET
Blazor
Blazor component model, rendering modes, and interactive web UI development in .NET
Configuration
.NET configuration system, options pattern, and secrets management
Entity Framework
Entity Framework Core ORM for data access, migrations, and query optimization in .NET
Mediatr
MediatR library for implementing CQRS, commands, queries, and pipeline behaviors in .NET