Every ASP.NET Core project starts the same way. You open Program.cs, call builder.Services.AddScoped<IOrderService, OrderService>() a handful of times, run the app, and everything just works. Dependency injection at this scale feels almost invisible — you barely think about it. The trouble is that this early simplicity teaches the wrong lesson. It suggests that DI is a solved problem you configure once and forget. On a real enterprise API, with dozens of feature modules, background workers, message consumers, and third-party integrations, the service collection eventually holds a hundred-plus registrations, and the lifetime, scoping, and composition decisions you made early on start to matter a great deal. Solid ASP.NET Core dependency injection patterns are what separate a codebase that scales gracefully from one where a memory leak or a stale-configuration bug shows up in production three months after the code was written and nobody can quite explain why.
This post is not a "what is dependency injection" primer. It assumes you already register services and inject constructors. Instead, it walks through the patterns and pitfalls that actually show up once an API grows past the "it fits in one file" stage: lifetime mismatches that only manifest under load, how to keep Program.cs from becoming an unreadable wall of registrations, the subtleties of IOptions variants, how to handle multiple implementations of the same interface cleanly, and why some very common shortcuts quietly sabotage your test suite. These are the ASP.NET Core dependency injection patterns that tend to separate teams that ship confidently from teams that dread touching the composition root.
The Three Lifetimes, Revisited for Scale
You already know the definitions. Transient services are created every time they are requested. Scoped services are created once per request (or once per explicitly created scope, such as inside a background job or a message handler). Singleton services are created once for the lifetime of the application. What matters at scale is not the definitions but the failure modes that appear when a lifetime is chosen incorrectly, and how those failures behave differently depending on traffic patterns and load.
A transient registered where a scoped service would do is usually just a minor performance cost — you allocate more objects than necessary. Rarely a correctness bug on its own. A scoped service registered as a singleton, or a singleton that reaches into the container and pulls out something scoped, is the far more dangerous mistake, and it is common enough in large codebases that it has its own name.
The Captive Dependency Problem
The captive dependency problem occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime. The classic case: a singleton depends on (or "captures") a scoped service, usually a DbContext, an HttpContext-aware service, or anything registered as scoped for correctness reasons. Because the singleton is constructed exactly once, the scoped dependency it grabs at construction time is also effectively frozen for the lifetime of the application — even though the scoped service was designed to be recreated per request.
The danger here isn't theoretical. A captured DbContext inside a singleton will happily serve every request from the same instance, and Entity Framework Core's change tracker is not thread-safe. Under low traffic this might go unnoticed for weeks. Under concurrent load it produces intermittent, hard-to-reproduce exceptions, corrupted tracked entities, or stale data being returned to unrelated requests. This is exactly the kind of bug that eats a full day of an on-call engineer's time because it cannot be reproduced locally with a single request.
Here is a simplified but realistic version of the bug. Imagine a singleton caching service that, for convenience, was given a direct dependency on a repository:
// BUGGY: singleton captures a scoped dependency
public class PricingCache : IPricingCache
{
private readonly IProductRepository _repository; // scoped underneath
public PricingCache(IProductRepository repository)
{
_repository = repository;
}
public async Task<decimal> GetPriceAsync(int productId)
{
var product = await _repository.GetByIdAsync(productId);
return product.Price;
}
}
// Program.cs
builder.Services.AddScoped<IProductRepository, ProductRepository>(); // uses DbContext
builder.Services.AddSingleton<IPricingCache, PricingCache>();
With the default ASP.NET Core service provider, this registration will actually throw an InvalidOperationException at resolution time if you have scope validation enabled (which is on by default in the Development environment, but easy to miss in Staging or Production if the environment isn't configured the same way). If scope validation is off, the app will start fine and fail silently in exactly the way described above — the first request's IProductRepository, and its underlying DbContext, gets frozen inside the singleton forever.
The fix depends on what the singleton actually needs. Usually it does not need the scoped service directly — it needs the ability to create a fresh scoped instance whenever it does work. That's what IServiceScopeFactory is for:
// FIXED: singleton creates a scope on demand instead of capturing one
public class PricingCache : IPricingCache
{
private readonly IServiceScopeFactory _scopeFactory;
public PricingCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<decimal> GetPriceAsync(int productId)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IProductRepository>();
var product = await repository.GetByIdAsync(productId);
return product.Price;
}
}
Each call now gets its own scope, and therefore its own DbContext, which is disposed cleanly when the scope goes out of scope. If the singleton genuinely needs to hold long-lived state (which is often the actual point of making something a singleton, such as an in-memory cache), that state should be limited to thread-safe, lifetime-appropriate data — not a scoped service reference. A good rule of thumb for enterprise APIs: singletons should depend only on other singletons, or on factories/providers that can manufacture scoped instances on demand. Anything else should raise a flag in code review.
Organizing Service Registration for a Large API
A Program.cs with three registrations is fine. A Program.cs with a hundred and thirty registrations, mixed in with middleware configuration, authentication setup, and Swagger options, is not something anyone wants to open. The problem isn't just aesthetics — a monolithic composition root makes it hard to tell which services belong to which feature, makes merge conflicts constant when multiple teams touch the same file, and makes it easy to accidentally register something twice with conflicting lifetimes.
The pattern that scales well is to group registrations by feature or module using extension methods on IServiceCollection, each living next to the code it registers. Program.cs then reads as a short list of module names rather than a wall of implementation details.
// Features/Orders/OrderServiceCollectionExtensions.cs
public static class OrderServiceCollectionExtensions
{
public static IServiceCollection AddOrderServices(this IServiceCollection services)
{
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IOrderValidator, OrderValidator>();
services.AddTransient<IOrderNumberGenerator, OrderNumberGenerator>();
return services;
}
}
// Features/Payments/PaymentServiceCollectionExtensions.cs
public static class PaymentServiceCollectionExtensions
{
public static IServiceCollection AddPaymentServices(this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<PaymentOptions>(configuration.GetSection("Payments"));
services.AddScoped<IPaymentGatewayFactory, PaymentGatewayFactory>();
services.AddScoped<StripeGateway>();
services.AddScoped<PayPalGateway>();
services.AddScoped<IPaymentService, PaymentService>();
return services;
}
}
// Program.cs
builder.Services
.AddOrderServices()
.AddPaymentServices(builder.Configuration)
.AddInventoryServices()
.AddNotificationServices(builder.Configuration)
.AddIdentityServices(builder.Configuration);
This convention has a few practical benefits beyond readability. It becomes much easier to write integration tests that spin up only the modules relevant to the test. It gives you a natural place to document why a particular lifetime was chosen — a comment on AddOrderServices() is far more likely to be read than a comment buried mid-file in Program.cs. And it makes ownership obvious: if the payments team is refactoring their module, they touch one file, not a shared one that every team edits.
One caution worth calling out: extension methods make it easy to register the same service twice from two different modules without noticing, particularly for cross-cutting things like HttpClient configuration or caching. It's worth a periodic audit — or a startup-time check using services.BuildServiceProvider().GetServices<T>() in a test — to confirm you don't have duplicate or conflicting registrations hiding behind tidy-looking extension methods.
Configuration-Driven Services: IOptions, IOptionsSnapshot, and IOptionsMonitor
Most enterprise APIs have services whose behavior depends on configuration — timeout values, feature flags, third-party API keys, retry counts. The options pattern is the idiomatic way to bind that configuration to strongly typed classes, but the three interfaces built around it — IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T> — behave differently in ways that matter once configuration is expected to change without a redeploy.
IOptions<T> is resolved once and cached for the lifetime of the application. It is itself registered as a singleton, which means it is safe to inject into other singletons, but it will never pick up configuration changes after startup, even if you're using a reloadable configuration source such as appsettings.json with reloadOnChange: true or Azure App Configuration.
IOptionsSnapshot<T> is recomputed once per scope (effectively once per request in a typical web API). It picks up configuration changes between requests, which makes it a good default for scoped services that should reflect near-live configuration without any extra plumbing.
IOptionsMonitor<T> gives you the current value on demand via .CurrentValue and can notify you of changes via .OnChange(...). Because it is registered as a singleton, it is the correct choice when a singleton service needs configuration that can change at runtime — it solves the same problem IOptionsSnapshot solves for scoped services, but for services that live for the whole application lifetime.
The mistake that shows up repeatedly in larger codebases is injecting IOptionsSnapshot<T> into a singleton. Because IOptionsSnapshot is designed around scope resolution, using it from a singleton silently defeats the purpose — you'll typically end up with the options value from whenever the singleton was first constructed, frozen for the app's lifetime, which is the exact same captive-dependency shape discussed above, just with configuration instead of a repository. The DI container won't necessarily throw here the way it might for a scoped service captured directly, because IOptionsSnapshot<T> itself is registered as scoped — so this is a case worth specifically checking for in code review, since it can pass casual testing and only surface as "why isn't this config change taking effect" days later.
A second common mistake is the reverse: reaching for IOptionsMonitor<T> everywhere out of habit, including in transient or scoped services where IOptionsSnapshot<T> or even plain IOptions<T> would be simpler and clearer. IOptionsMonitor is the right tool specifically when you're inside a singleton and need live values, or when you need to react to a change event. Outside that case, it adds an indirection that isn't buying you anything.
A concrete example: a rate-limiting middleware component is typically registered as a singleton for performance reasons, and its limits are exactly the kind of thing an operations team wants to tune without redeploying the whole API.
public class RateLimitOptions
{
public int RequestsPerMinute { get; set; } = 100;
}
public class TokenBucketRateLimiter
{
private readonly IOptionsMonitor<RateLimitOptions> _options;
public TokenBucketRateLimiter(IOptionsMonitor<RateLimitOptions> options)
{
_options = options;
}
public bool IsAllowed(string clientId)
{
var limit = _options.CurrentValue.RequestsPerMinute;
// ... bucket logic using the live limit value
return true;
}
}
Because the limiter reads _options.CurrentValue on every call rather than caching the value in a field at construction time, a configuration change in appsettings.json (or a reloadable provider) is reflected on the very next request, with no restart required.
Factory Patterns and Keyed Registrations for Multiple Implementations
It's common in enterprise APIs to have several implementations of the same interface selected at runtime — multiple payment gateways, multiple notification channels, multiple document storage providers depending on tenant configuration. The default container does support registering multiple implementations of an interface, but resolving "the one I need right now" out of that list requires a deliberate pattern; naive constructor injection of the interface only ever gets you the last registered implementation.
There are two solid approaches. The older, still very common one is a factory that resolves the correct implementation based on a key:
public interface IPaymentGateway
{
string ProviderName { get; }
Task<PaymentResult> ChargeAsync(PaymentRequest request);
}
public interface IPaymentGatewayFactory
{
IPaymentGateway Create(string providerName);
}
public class PaymentGatewayFactory : IPaymentGatewayFactory
{
private readonly IEnumerable<IPaymentGateway> _gateways;
public PaymentGatewayFactory(IEnumerable<IPaymentGateway> gateways)
{
_gateways = gateways;
}
public IPaymentGateway Create(string providerName)
{
var gateway = _gateways.FirstOrDefault(g =>
g.ProviderName.Equals(providerName, StringComparison.OrdinalIgnoreCase));
if (gateway is null)
{
throw new NotSupportedException($"No payment gateway registered for '{providerName}'.");
}
return gateway;
}
}
// Registration
builder.Services.AddScoped<IPaymentGateway, StripeGateway>();
builder.Services.AddScoped<IPaymentGateway, PayPalGateway>();
builder.Services.AddScoped<IPaymentGatewayFactory, PaymentGatewayFactory>();
This works well and is easy for teams to reason about, but resolving via FirstOrDefault over a live enumeration does construct every registered gateway even though only one is used, which can matter if your implementations have non-trivial construction costs.
Since .NET 8, the built-in container supports keyed services, which handle this scenario more directly without the manual lookup logic:
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, PayPalGateway>("paypal");
public class PaymentService : IPaymentService
{
private readonly IServiceProvider _serviceProvider;
public PaymentService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<PaymentResult> ChargeAsync(string provider, PaymentRequest request)
{
var gateway = _serviceProvider.GetRequiredKeyedService<IPaymentGateway>(provider);
return await gateway.ChargeAsync(request);
}
}
Keyed services are a genuinely useful addition for exactly this "multiple implementations, resolved by runtime key" scenario, and they remove the need for a hand-rolled factory class for the simple cases. That said, notice that PaymentService above still needs to inject IServiceProvider directly to call GetRequiredKeyedService, since constructor injection can't express "give me the implementation matching this runtime string." That's a legitimate, narrow use of the service provider — quite different from the anti-pattern discussed next.
Testing Implications of DI Choices
The single biggest practical payoff of consistent ASP.NET Core dependency injection patterns is what they do for your test suite. Constructor injection against interfaces means every dependency a class needs is visible in its constructor signature. That single property makes unit testing straightforward: you can construct the class under test with mocked or fake implementations of each dependency, without touching a real database, a real HTTP client, or the DI container at all.
public class OrderServiceTests
{
[Fact]
public async Task PlaceOrder_WithInsufficientStock_ThrowsException()
{
var repository = new Mock<IOrderRepository>();
var inventory = new Mock<IInventoryService>();
inventory.Setup(i => i.IsInStockAsync(It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(false);
var sut = new OrderService(repository.Object, inventory.Object);
await Assert.ThrowsAsync<InsufficientStockException>(
() => sut.PlaceOrderAsync(new OrderRequest { ProductId = 1, Quantity = 5 }));
}
}
No container, no web host, no configuration files — just a class and its declared dependencies. This is the payoff that makes disciplined DI worth the upfront thought.
The anti-pattern that undermines this benefit is the service locator pattern: a class that injects IServiceProvider and pulls out whatever it needs internally, rather than declaring those needs in its constructor.
// ANTI-PATTERN: dependencies are hidden inside the method body
public class OrderService
{
private readonly IServiceProvider _serviceProvider;
public OrderService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task PlaceOrderAsync(OrderRequest request)
{
var repository = _serviceProvider.GetRequiredService<IOrderRepository>();
var inventory = _serviceProvider.GetRequiredService<IInventoryService>();
// ...
}
}
This compiles, it runs, and it can even look like it "simplifies" the constructor. But it hides the class's real dependencies from anyone reading the constructor signature, from static analysis tools, and from the DI container's own validation — a missing registration for IInventoryService won't surface until that specific code path executes at runtime. Worse for day-to-day development, it makes the class effectively untestable in isolation: to unit test it, you now need to build a working IServiceProvider with every dependency it might reach for internally. Teams that lean on the service locator pattern tend to end up with thinner unit test suites and more reliance on slow, brittle integration tests, simply because the ergonomics push them that way.
The fix is almost always to make the dependency explicit in the constructor. There are a small number of legitimate exceptions — the keyed-service lookup shown earlier, or infrastructure code that genuinely needs to resolve services dynamically based on runtime type information it can't know at compile time — but those should be rare, isolated, and clearly commented, not the default way a service gets its dependencies.
Bringing It Together
None of these patterns are exotic. Correct lifetimes, modular registration, the right IOptions variant, explicit factories instead of service location — each one is a small, well-documented idea. The difficulty in enterprise APIs isn't understanding any single pattern; it's applying them consistently across a codebase that multiple people touch over multiple years, where a single captured scoped dependency in a rarely-exercised singleton can sit dormant until a traffic spike finally triggers it. The official ASP.NET Core dependency injection documentation and the more general .NET dependency injection guidelines are worth keeping bookmarked — they're a good baseline, though the real judgment calls tend to come up in the specific shape of a given API rather than in the general rules.
The honest takeaway is that DI in ASP.NET Core doesn't get harder because the framework changes — it gets harder because the graph of services gets bigger, and small inconsistencies compound. Investing a little discipline early — extension methods per feature, correct options interfaces, explicit factories instead of service location — pays for itself well before the API reaches a hundred registrations. It's rarely the exciting part of the job, but it's usually the difference between an API that stays debuggable as it grows and one that doesn't.
If you're evaluating help building or refactoring an enterprise ASP.NET Core API — service lifetimes, composition root organization, or general architecture — you can see more about this kind of work on the hire an ASP.NET Core developer page.
Top comments (0)