12 Rules for Dependency Injection in ASP.NET Core
Master Dependency Injection in ASP.NET Core with 12 practical rules. Covers service lifetimes, interface injection, IServiceScopeFactory, extension methods, circular dependencies, IOptions, and avoiding the Service Locator anti-pattern.
Why DI Rules Matter
Dependency Injection in ASP.NET Core is powerful — and easy to misuse. A wrong lifetime choice causes memory leaks. A captive dependency causes stale data. The Service Locator pattern hides your real dependencies. Circular references crash your app at startup.
These 12 rules — popularised by Aram Tchekrekjian (@AramT87) — are the practical checklist every .NET developer should know before wiring up their first service.
Rule 1: Use Transient for Stateless Short-Lived Services
Transient services are created fresh every time they are resolved. They are the safest lifetime for stateless services — helpers, mappers, calculators — because they carry no state between calls.
// ✅ Good candidates for Transient
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();
builder.Services.AddTransient<IOrderMapper, OrderMapper>();
builder.Services.AddTransient<IPriceCalculator, PriceCalculator>();
// What Transient means in practice
public class OrderController : ControllerBase
{
private readonly IPriceCalculator _calculator;
// Every time OrderController is created, a NEW PriceCalculator is injected
public OrderController(IPriceCalculator calculator)
=> _calculator = calculator;
}Use Transient when:
- The service has no state
- The service is cheap to create
- Each caller should get an independent instance
Avoid Transient when:
- The service holds a database connection, HTTP connection, or any expensive resource — creating it repeatedly wastes resources
Rule 2: Use Scoped per Request for Services like EF Core DbContext
Scoped services are created once per HTTP request (or per DI scope). All components handling the same request share the same instance.
DbContext must be Scoped — it tracks entity changes and coordinates SaveChanges across the request. Creating a new one per component would lose tracked changes.
// ✅ Scoped — one instance per request
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// AddDbContext registers as Scoped by default
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
// All three share the same DbContext within one HTTP request
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _db;
public UnitOfWork(AppDbContext db) => _db = db;
// Same _db instance as the repository above — changes are visible across both
}Use Scoped when:
- The service tracks per-request state (
DbContext, current user, request correlation) - Multiple services in the same request should share the instance
- The instance should not outlive the request
Rule 3: Use Singleton for Shared Services like Config Loaders, Caching, or Logging
Singleton services are created once and reused for the entire application lifetime. All requests, all threads, share the same instance.
// ✅ Good candidates for Singleton
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IMetricsService, MetricsService>();
// ILogger<T> is Singleton by default — safe because it's thread-safe
builder.Services.AddLogging();Singleton requirements:
- Must be thread-safe — multiple requests access it concurrently
- Should hold no request-specific state
- Expensive-to-create services benefit most (connection pools, config readers)
// ✅ Thread-safe Singleton using ConcurrentDictionary
public class InMemoryCacheService : ICacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();
public T? Get<T>(string key)
=> _cache.TryGetValue(key, out var value) ? (T)value : default;
public void Set<T>(string key, T value)
=> _cache[key] = value!;
}Rule 4: Inject Interfaces Only
Always depend on abstractions, not concrete types. This is the Dependency Inversion Principle applied to DI.
// ❌ Bad — depends on concrete type
public class OrderService
{
private readonly SqlOrderRepository _repo; // tied to SQL implementation
public OrderService(SqlOrderRepository repo) => _repo = repo;
}
// ✅ Good — depends on abstraction
public class OrderService
{
private readonly IOrderRepository _repo; // any implementation works
public OrderService(IOrderRepository repo) => _repo = repo;
}
// Now you can:
// - Swap to a different DB without changing OrderService
// - Inject a mock in tests
// - Add a caching decorator transparently
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
// or
builder.Services.AddScoped<IOrderRepository, CachedOrderRepository>();Benefits of interface injection:
- Testability — mock the interface in unit tests
- Flexibility — swap implementations via config
- Decoration — wrap with caching, logging, retry without touching consumers
Rule 5: Keep Dependencies Minimal
Every constructor parameter is a dependency. Too many dependencies signal that a class is doing too much — violating the Single Responsibility Principle.
// ❌ Bad — 7 constructor parameters
public class OrderService
{
public OrderService(
IOrderRepository orders,
ICustomerRepository customers,
IProductRepository products,
IInventoryService inventory,
IEmailService email,
IPaymentService payment,
IAuditService audit) { }
}
// ✅ Better — split by responsibility
// OrderCreationService — handles creation only
public class OrderCreationService
{
public OrderCreationService(
IOrderRepository orders,
IInventoryService inventory,
IPaymentService payment) { }
}
// OrderNotificationService — handles notifications only
public class OrderNotificationService
{
public OrderNotificationService(
IEmailService email,
IAuditService audit) { }
}Rule of thumb: more than 4–5 constructor parameters is a code smell. Consider splitting the class or introducing a facade.
Rule 6: Avoid Transient in Singleton to Prevent Errors
Injecting a Transient (or Scoped) service into a Singleton creates a captive dependency. The Singleton captures the Transient at construction time and holds it for the app's lifetime — defeating the purpose of the shorter lifetime.
// ❌ Bad — Transient captured in Singleton
public class ReportingService // registered as Singleton
{
private readonly IOrderMapper _mapper; // registered as Transient
public ReportingService(IOrderMapper mapper)
=> _mapper = mapper; // _mapper is now held forever — same instance always reused
}
// ❌ Even worse — DbContext (Scoped) captured in Singleton
public class CacheRefreshService // registered as Singleton
{
private readonly AppDbContext _db; // Scoped — should only live one request
// This DbContext will never be disposed — memory leak + stale data
}ASP.NET Core detects Scoped-in-Singleton by default in Development:
InvalidOperationException: Cannot consume scoped service 'AppDbContext'
from singleton 'CacheRefreshService'.To enable this check in all environments:
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // detect Scoped-in-Singleton
options.ValidateOnBuild = true; // detect at startup, not first request
});Rule 7: To Use a Scoped Service in a Singleton, Resolve from IServiceScopeFactory
When a Singleton genuinely needs a Scoped service (e.g., a background service that needs DbContext), do not inject it directly. Instead, inject IServiceScopeFactory and create a scope explicitly for each unit of work.
// ✅ Correct pattern — create a scope for each operation
public class OrderCleanupService : BackgroundService // Singleton (hosted service)
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderCleanupService> _logger;
public OrderCleanupService(
IServiceScopeFactory scopeFactory,
ILogger<OrderCleanupService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await CleanupExpiredOrdersAsync(ct);
await Task.Delay(TimeSpan.FromHours(1), ct);
}
}
private async Task CleanupExpiredOrdersAsync(CancellationToken ct)
{
// Create a fresh scope — fresh DbContext — for this operation
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var expired = await db.Orders
.Where(o => o.Status == OrderStatus.Pending
&& o.CreatedAt < DateTime.UtcNow.AddDays(-1))
.ToListAsync(ct);
// ... process and save
await db.SaveChangesAsync(ct);
// scope.Dispose() is called here — DbContext is properly disposed
}
}Rule 8: Use Extension Methods to Organise Your Program.cs
As a project grows, Program.cs with 200 lines of builder.Services.Add... becomes unmaintainable. Extension methods group related registrations by concern.
// ❌ Bad — everything in Program.cs
builder.Services.AddDbContext<AppDbContext>(...);
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddStackExchangeRedisCache(...);
builder.Services.AddAuthentication(...).AddJwtBearer(...);
// ... 100 more lines
// ✅ Good — extension methods by layer
// Infrastructure/DependencyInjection.cs
public static class InfrastructureDependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
services.AddSingleton<ICacheService, RedisCacheService>();
services.AddStackExchangeRedisCache(options =>
options.Configuration = configuration.GetConnectionString("Redis"));
return services;
}
}
// Application/DependencyInjection.cs
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(
this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}
// Program.cs — clean and readable
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration)
.AddPresentation();Rule 9: Validate Program.cs to Catch DI Misconfigurations Early in Dev
DI misconfigurations (missing registrations, wrong lifetimes) fail at runtime — sometimes only when a specific request hits a specific code path. Catch them at startup instead.
// Enable build-time and scope validation in Development
builder.Host.UseDefaultServiceProvider((context, options) =>
{
// ValidateOnBuild — resolve every registered service at startup
// catches: missing registrations, unresolvable dependencies
options.ValidateOnBuild = context.HostingEnvironment.IsDevelopment();
// ValidateScopes — detect Scoped-in-Singleton
options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
});With ValidateOnBuild = true, the app throws at startup — not on the first request to a broken endpoint:
Unhandled exception. System.AggregateException:
Some services are not able to be constructed
(Error while validating the service descriptor
'ServiceType: IOrderService Lifetime: Scoped ...'
Unable to resolve service for type 'IPaymentGateway')This is infinitely better than a production crash.
Rule 10: Avoid GetService as Service Locator — It Hides Real Dependencies
The Service Locator pattern resolves services from the container inside a class. It looks like DI but has none of the benefits.
// ❌ Bad — Service Locator pattern
public class OrderService
{
private readonly IServiceProvider _serviceProvider;
public OrderService(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public async Task ProcessAsync(Order order)
{
// Hidden dependency — you can't see it from the constructor
var paymentService = _serviceProvider.GetService<IPaymentService>();
await paymentService!.ChargeAsync(order.Total);
}
}Problems with Service Locator:
- Hidden dependencies — reading the constructor doesn't tell you what the class needs
- Harder to test — you must configure the full container to test it
- Fragile at runtime —
GetService<T>returns null if T isn't registered;GetRequiredService<T>throws - Violates the DI principle — the container leaks into business logic
// ✅ Good — explicit constructor injection
public class OrderService
{
private readonly IPaymentService _payment; // visible dependency
public OrderService(IPaymentService payment)
=> _payment = payment;
public async Task ProcessAsync(Order order)
=> await _payment.ChargeAsync(order.Total);
}The only legitimate use of IServiceProvider.GetService: in extension methods, factories, or middleware where constructor injection isn't available (e.g., IMiddlewareFactory, IHostedService factory methods).
Rule 11: Prevent Circular DI to Avoid Runtime Failures
Circular dependencies occur when Service A depends on Service B, which depends on Service A. The DI container cannot resolve either — the app crashes at runtime.
// ❌ Bad — circular dependency
public class OrderService
{
public OrderService(ICustomerService customerService) { }
}
public class CustomerService : ICustomerService
{
public CustomerService(IOrderService orderService) { } // ← OrderService needs CustomerService needs OrderService
}InvalidOperationException: A circular dependency was detected for the service of type 'IOrderService'.
IOrderService -> OrderService -> ICustomerService -> CustomerService -> IOrderServiceHow to fix circular dependencies:
// Fix 1: Extract shared logic into a third service
public class OrderCustomerRelationService
{
// logic that both needed from each other lives here
}
public class OrderService
{
public OrderService(IOrderCustomerRelationService relation) { }
}
public class CustomerService : ICustomerService
{
public CustomerService(IOrderCustomerRelationService relation) { }
}
// Fix 2: Use an event/mediator to decouple
public class OrderService
{
private readonly IMediator _mediator;
// No direct dependency on CustomerService
// Publishes events; CustomerService handles them
}
// Fix 3: Lazy<T> to break the construction cycle (last resort)
public class OrderService
{
private readonly Lazy<ICustomerService> _customer;
public OrderService(Lazy<ICustomerService> customer) => _customer = customer;
// _customer.Value is resolved only when first accessed, breaking the cycle
}Circular dependencies are always a design smell. The fix is to rethink the responsibility boundaries — not to work around the cycle.
Rule 12: Use IOptions to Inject Config Settings
Never inject IConfiguration directly into services. IConfiguration is a raw bag of strings. IOptions<T> provides typed, validated configuration.
// ❌ Bad — inject raw IConfiguration
public class EmailService
{
private readonly IConfiguration _config;
public EmailService(IConfiguration config) => _config = config;
public async Task SendAsync(string to, string subject)
{
var host = _config["Email:SmtpHost"]; // string — no validation, no type safety
var port = int.Parse(_config["Email:Port"]!); // crashes if missing
}
}
// ✅ Good — typed configuration with IOptions<T>
// 1. Define the settings class
public class EmailSettings
{
public const string SectionName = "Email";
[Required]
public string SmtpHost { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true;
[Required]
public string FromAddress { get; set; } = "";
}
// 2. Register with validation
builder.Services.AddOptions<EmailSettings>()
.BindConfiguration(EmailSettings.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // validate at startup, not first use
// 3. Inject
public class EmailService
{
private readonly EmailSettings _settings;
public EmailService(IOptions<EmailSettings> options)
=> _settings = options.Value;
public async Task SendAsync(string to, string subject)
{
// Type-safe, validated, always present
var host = _settings.SmtpHost;
var port = _settings.Port;
}
}IOptions vs IOptionsSnapshot vs IOptionsMonitor
// IOptions<T> — Singleton lifetime, values fixed at startup
// Use for: most services, values that don't change at runtime
public class OrderService(IOptions<OrderSettings> opts) { }
// IOptionsSnapshot<T> — Scoped lifetime, re-reads per request
// Use for: services that need fresh config per request (feature flags, A/B settings)
public class FeatureService(IOptionsSnapshot<FeatureSettings> opts) { }
// IOptionsMonitor<T> — Singleton, notified when config file changes at runtime
// Use for: Singleton services that should react to live config changes
public class CacheService(IOptionsMonitor<CacheSettings> monitor)
{
public CacheService(IOptionsMonitor<CacheSettings> monitor)
{
monitor.OnChange(settings =>
{
// Reconfigure when appsettings.json changes on disk
});
}
}Summary: The 12 Rules at a Glance
| # | Rule | Why |
|---|---|---|
| 1 | Transient for stateless short-lived services | New instance every time — no shared state |
| 2 | Scoped for EF Core DbContext and request-bound services | One instance per request, shared within it |
| 3 | Singleton for config, caching, logging | Shared across all requests, must be thread-safe |
| 4 | Inject interfaces, not concrete types | Testable, swappable, decoratable |
| 5 | Keep dependencies minimal | >4-5 params = SRP violation |
| 6 | Avoid Transient/Scoped in Singleton | Captive dependency = stale data or memory leak |
| 7 | Use IServiceScopeFactory for Scoped inside Singleton | Create and dispose a scope per operation |
| 8 | Extension methods for Program.cs | Readable, layered, maintainable |
| 9 | ValidateOnBuild = true in Development | Catch misconfigs at startup, not first request |
| 10 | Avoid Service Locator (GetService<T>) | Hides dependencies, breaks testability |
| 11 | Prevent circular dependencies | Crashes at runtime, always a design smell |
| 12 | IOptions<T> for config settings | Typed, validated, startup-checked |
Interview Questions
Q: What is the difference between Transient, Scoped, and Singleton lifetimes?
Transient: new instance every resolution — for stateless services. Scoped: one instance per request/scope — for DbContext, per-request state. Singleton: one instance for the entire app — for thread-safe shared services like caches and config. The key question is: does this service need to share state within a request? If yes, Scoped. Does it need to share state across all requests? Singleton. Otherwise, Transient.
Q: What is a captive dependency and why is it dangerous?
When a longer-lived service (Singleton) holds a reference to a shorter-lived service (Scoped or Transient). The inner service is "captured" at the Singleton's creation and never replaced — even though it was meant to be per-request. A captured DbContext causes stale data reads and memory leaks. Fix: inject IServiceScopeFactory and create a fresh scope per operation.
Q: Why is the Service Locator pattern an anti-pattern?
It hides dependencies — the class's constructor doesn't reveal what it needs. It couples business logic to the DI container. It makes testing harder — you must configure the full container. And GetService<T> returns null on missing registrations, causing NullReferenceExceptions instead of clear startup errors. Use constructor injection instead.
Q: What does ValidateOnBuild do?
Forces the DI container to try resolving every registered service at application startup. Missing registrations and unresolvable dependencies throw immediately — before any request is handled. Without it, a misconfiguration is only discovered when the specific endpoint that needs the broken service is first called, potentially in production.
Q: When would you use IOptionsMonitor instead of IOptions?
When a Singleton service needs to react to configuration changes at runtime without restarting. IOptions<T> reads config once at startup. IOptionsMonitor<T> fires a callback whenever the configuration source changes (e.g., appsettings.json is reloaded or Azure App Configuration refreshes). Useful for feature flags, rate limits, or cache TTLs that should update without a deployment.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.