ine abstractions, register with explicit lifetimes, resolve via constructors, and validate at startup.
Step 1: Define Explicit Contracts
Services must be registered against interfaces, not concrete types. This enforces inversion of control and enables test doubles.
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id, CancellationToken ct);
}
public interface IEmailService
{
Task SendAsync(string to, string subject, string body, CancellationToken ct);
}
Step 2: Implement with Single Responsibility
Concrete classes should depend only on their declared interfaces. Avoid framework-specific types in service logic.
public class SqlOrderRepository : IOrderRepository
{
private readonly DbContext _context;
public SqlOrderRepository(AppDbContext context) => _context = context;
public async Task<Order> GetByIdAsync(Guid id, CancellationToken ct)
{
return await _context.Orders.FindAsync(new object[] { id }, ct)
?? throw new KeyNotFoundException();
}
}
public class SmtpEmailService : IEmailService
{
private readonly SmtpClient _client;
public SmtpEmailService(IOptions<SmtpSettings> settings)
{
_client = new SmtpClient(settings.Value.Host)
{
Credentials = new NetworkCredential(settings.Value.User, settings.Value.Pass)
};
}
public Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
var message = new MailMessage("noreply@domain.com", to, subject, body);
return _client.SendMailAsync(message, ct);
}
}
Step 3: Register with Explicit Lifetimes
Lifetimes dictate disposal behavior and thread safety.
AddTransient: New instance per resolution. Safe for stateless, lightweight services.
AddScoped: Single instance per HTTP request or async scope. Required for DbContext, unit-of-work, and request-bound state.
AddSingleton: Single instance for application lifetime. Thread-safe only if stateless or explicitly synchronized.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IConfigurationProvider, AppConfigProvider>();
Step 4: Enforce Constructor Injection
Property or method injection breaks explicit contracts and hides dependencies. Constructor injection guarantees required services are available at instantiation.
public class OrderProcessor
{
private readonly IOrderRepository _repository;
private readonly IEmailService _email;
// Required dependencies are explicit and fail-fast if missing
public OrderProcessor(IOrderRepository repository, IEmailService email)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_repository = repository;
_email = email ?? throw new ArgumentNullException(nameof(email));
}
}
Step 5: Validate at Startup
.NET 8+ supports scope validation to catch captive dependencies before deployment.
builder.Host.UseServiceProviderFactory(
new DefaultServiceProviderFactory(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
}));
Architecture Decisions
- Interface-first registration: Prevents concrete coupling and enables mock injection in tests.
- Constructor-only resolution: Eliminates hidden dependencies and guarantees fail-fast behavior.
- Scope validation: Catches singleton→scoped→transient violations at build time, not runtime.
- Options pattern integration: Configuration binds to
IOptions<T> rather than raw IConfiguration, enabling strong typing and change tracking.
Pitfall Guide
1. Captive Dependencies
Registering a scoped service as a dependency of a singleton creates a captive dependency. The scoped instance is promoted to singleton lifetime, causing state leakage across requests.
Fix: Use IServiceScopeFactory to create explicit scopes when a singleton requires scoped dependencies.
public class BackgroundWorker
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task ProcessAsync()
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
// Use repo within explicit scope
}
}
2. Service Locator Anti-Pattern
Resolving dependencies via IServiceProvider inside business logic hides contracts and breaks testability.
Fix: Inject dependencies via constructors. Use IServiceProvider only in composition roots or infrastructure adapters.
3. Overusing Transient for Heavy Services
Registering DbContext or HTTP clients as transient creates unnecessary allocations and connection pool exhaustion.
Fix: Match lifetime to resource scope. DbContext is scoped. HttpClient is singleton or typed client.
4. Ignoring IDisposable and Lifecycle Disposal
The container disposes resolved instances when their scope ends. Services implementing IDisposable must be registered correctly. Transient disposables require explicit disposal or factory management.
Fix: Prefer IAsyncDisposable for network/database resources. Let the container manage disposal for scoped/singletons.
5. Circular Dependencies
A depends on B, B depends on A. The container throws InvalidOperationException during resolution.
Fix: Extract shared behavior into a third service, use lazy injection (Lazy<T>), or redesign boundaries.
6. Registering Concretes Instead of Abstractions
services.AddScoped<SqlOrderRepository>() prevents mocking and violates DIP.
Fix: Always register against interfaces. services.AddScoped<IOrderRepository, SqlOrderRepository>().
7. Skipping DI Validation in CI/CD
Runtime scope violations surface in production under load.
Fix: Run ValidateOnBuild = true in all environments. Add a CI step that instantiates the service provider and resolves critical graphs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Web API request handling | Scoped registration for repositories, DbContext, and request state | Aligns with HTTP pipeline lifetime; prevents cross-request state leakage | Low memory, high testability |
| Background processing / IHostedService | Singleton host + IServiceScopeFactory for scoped resolution | Prevents captive dependencies; enables safe parallel execution | Slight code complexity, eliminates memory leaks |
| Unit testing | Manual DI or Microsoft.Extensions.DependencyInjection test harness | Guarantees isolated graphs; avoids container overhead in tests | Faster CI, deterministic mocks |
| High-throughput stateless service | Transient registration with object pooling if allocation-heavy | Zero state contamination; container manages lifecycle | Higher GC pressure, mitigated by pooling |
Configuration Template
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Enable strict DI validation
builder.Host.UseServiceProviderFactory(
new DefaultServiceProviderFactory(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
}));
// Configuration binding
builder.Services.Configure<DatabaseSettings>(builder.Configuration.GetSection("Database"));
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
// Service registration
builder.Services.AddTransient<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IConfigurationProvider, AppConfigProvider>();
// Framework services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Validation assertion (fails fast if graph is broken)
_ = app.Services.GetRequiredService<IOrderService>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Quick Start Guide
- Define contracts: Create interfaces for every service that will be injected. Avoid framework types in signatures.
- Register in Program.cs: Use
AddTransient, AddScoped, or AddSingleton based on resource scope. Always register against interfaces.
- Inject via constructors: Replace property/method injection with constructor parameters. Add null checks or use
ArgumentNullException for fail-fast behavior.
- Enable validation: Configure
ServiceProviderOptions with ValidateScopes = true and ValidateOnBuild = true. Run the application locally; the container will throw on graph violations.
- Test isolation: Replace container resolution with manual DI in unit tests. Instantiate services with mocks to verify behavior without infrastructure dependencies.