7 Common ASP.NET Core Dependency Injection Mistakes (And How to Fix Them)
Dependency injection is baked into ASP.NET Core so deeply that most developers start using it before fully understanding it. That's fine โ until production. When DI goes wrong, the failures are subtle: memory leaks that grow slowly over days, stale data that appears only under concurrent load, InvalidOperationException crashes that only reproduce in staging, and objects that live far longer than they should. These are not framework bugs. They are configuration mistakes that compound over time.
The full implementations โ including lifecycle validation, IServiceScopeFactory patterns, and edge-case handling โ are available as annotated, production-ready source code on Patreon, ready to drop into real ASP.NET Core projects.
Understanding these mistakes also matters beyond the immediate fix. DI lifetime decisions are architectural decisions. Getting them right in Chapter 1 of the Zero to Production course is one of the first things covered โ because everything that follows depends on services being registered with the right lifetime.
This article covers seven DI mistakes that appear repeatedly in ASP.NET Core codebases โ from startups to enterprise teams โ and explains the fix for each one.
Mistake 1: Injecting a Scoped Service Into a Singleton (The Captive Dependency)
This is the most well-known DI mistake in ASP.NET Core, and it still ships to production regularly. The term "captive dependency," coined by Mark Seemann, describes exactly what happens: a singleton holds a shorter-lived service captive, preventing it from being disposed and reusing stale state across requests.
A typical scenario: a singleton background processor injects AppDbContext directly via constructor injection. AppDbContext is registered as scoped โ one per request โ but the singleton is created once and never recreated. The captured DbContext instance is now shared across all concurrent operations for the lifetime of the application.
What actually goes wrong:
- EF Core's
DbContextis not thread-safe. Sharing it across concurrent requests causesInvalidOperationException: A second operation was started on this context before a previous operation completed. - Scoped services that cache per-request data (like a current user resolver or a tenant identifier) will return the data from the first request that initialized the singleton. Every subsequent request gets the wrong context.
- ASP.NET Core's scope validation (enabled by default in the Development environment) will throw an
InvalidOperationExceptionat startup if it detects this misconfiguration. Production builds often have scope validation disabled, which is why this can silently reach production.
The fix: When a singleton genuinely needs to access a scoped service, inject IServiceScopeFactory and create an explicit scope for each unit of work. This is the documented pattern for BackgroundService and hosted services that need database access. Creating a scope manually gives you control over the lifetime, and the scope โ along with everything it resolves โ is disposed correctly when the using block exits.
Alternatively, reconsider whether the service truly needs to be a singleton. Many services registered as singletons for performance reasons are perfectly safe as scoped โ and the performance difference is negligible compared to a single database round-trip.
Mistake 2: Using the Service Locator Pattern
The service locator pattern means calling IServiceProvider.GetService<T>() (or GetRequiredService<T>()) from inside a class to resolve dependencies on demand, rather than declaring them as constructor parameters. It is technically supported, but it is an anti-pattern in ASP.NET Core DI.
The problem is not correctness โ it works. The problem is that it hides dependencies. When a class takes IOrderRepository, IEmailSender, and IEventPublisher in its constructor, those dependencies are explicit. Every caller knows what this class needs. When a class takes IServiceProvider and resolves whatever it needs internally, none of that is visible.
Concrete consequences:
- Unit testing becomes painful. Mocking three concrete dependencies is straightforward. Mocking an
IServiceProviderthat returns the right thing for any type passed to it is not. - Misconfigured registrations fail at runtime, not at construction time. Constructor injection fails fast at startup. Service locator fails at the call site, which may be deep in a request lifecycle.
- Code that looks simple acquires hidden coupling to the entire DI container. Refactoring becomes harder as the true dependency graph is obscured.
The fix: Declare every dependency as a constructor parameter. If a class is growing to 6-8 constructor parameters, that is a signal that the class is doing too much โ not a reason to switch to service locator. Split the class first, then inject cleanly.
The only legitimate use case for service locator in ASP.NET Core is infrastructure code that genuinely cannot use constructor injection: middleware classes (where scoped services must be resolved in Invoke/InvokeAsync), factories that create objects based on runtime conditions, and bootstrapping code in Program.cs. Outside of those cases, constructor injection is always the right choice.
Mistake 3: Registering Services With the Wrong Lifetime
Choosing the wrong service lifetime is a common source of subtle bugs. The three lifetimes have clear meanings:
- Transient โ a new instance every time the service is resolved
- Scoped โ one instance per HTTP request (or per manually created scope)
- Singleton โ one instance for the entire application lifetime
The mistake is not always injecting scoped into singleton (Mistake 1). There are two other common misregistrations:
Transient services that hold state. A service registered as transient that holds internal mutable state (a counter, a cache, a buffer) will appear to work correctly in single-threaded tests but will produce incorrect results in production. Each resolution gets a fresh instance โ the state is never shared, never accumulated. If you need state that persists across calls within a request, the service should be scoped.
Singletons that use HttpContext. A singleton that takes IHttpContextAccessor will compile and run, but IHttpContextAccessor.HttpContext is only valid during an active request on the current thread. A singleton that caches HttpContext properties at construction time, or accesses them from a background thread, will read null or stale data. Services that are inherently per-request must be registered as scoped, full stop.
What is genuinely safe as singleton: Pure utility services with no mutable state, configuration wrappers, registered IOptions<T> snapshots, clients like HttpClient (managed through IHttpClientFactory), and external SDK clients explicitly designed for thread-safe singleton use.
The fix: Before registering any service, answer three questions: Does it hold mutable state that should be reset per request? Does it access HttpContext or any request-scoped data? Does it wrap a resource (like a DbContext) that is explicitly not thread-safe? If yes to any of these, the service should be scoped โ not singleton, not transient.
Mistake 4: Ignoring Disposable Transient Services
When a transient service implements IDisposable or IAsyncDisposable, the ASP.NET Core DI container takes ownership of its disposal โ but only when it is resolved from a scoped context. The container holds a reference to every disposable transient it creates so it can call Dispose() when the scope ends.
This is the intended behaviour. The problem arises when transient disposables are resolved from the root container โ directly from IServiceProvider during application startup, or in singleton services that create their own resolution context. In those cases, the root container holds the disposable for the entire application lifetime. There is no scope end to trigger disposal. The result is a memory leak that grows for as long as the application runs.
Why it is hard to catch: In development, applications restart frequently. The leak is invisible. In production, behind a load balancer with infrequent restarts, memory grows steadily. By the time it is investigated, the connection between the leak and the DI configuration is not obvious.
The fix: Avoid IDisposable on transient services unless the service genuinely manages a resource that must be released immediately. If disposal is needed, prefer scoped registration โ the scope provides a natural disposal boundary. When resolving transient disposables manually (in tests or factory code), use explicit using blocks with manually created scopes to ensure deterministic disposal.
Mistake 5: Over-Registering Services as Singletons for Performance
Singleton registration is sometimes chosen for performance reasons: "Why create a new instance on every request if the class is stateless?" This reasoning is often correct, but it leads to a habit of defaulting to singleton for anything that looks lightweight โ and that habit eventually produces the captive dependency problem at scale.
There is also a subtler issue: classes that appear stateless sometimes are not. Thread-local state, internal lazy-initialized caches, or dependencies that hold state (registered transitively through constructor injection) can all make a "stateless" class stateful in ways that are not obvious from the registration call.
The fix: Default to scoped for services that interact with any request-specific data, infrastructure, or external systems. Use singleton deliberately and explicitly only when you have confirmed the service is genuinely stateless and thread-safe. The performance cost of scoped over singleton is negligible for anything that does real work โ it is measured in nanoseconds of allocation, while your slowest database query is measured in milliseconds.
When in doubt, profile first. Do not optimise DI lifetime without a measurement that shows lifetime choice is the bottleneck.
Mistake 6: Registering Multiple Implementations and Resolving the Wrong One
ASP.NET Core allows multiple implementations of the same interface to be registered. When you call services.AddScoped<INotificationService, EmailNotificationService>() and later call services.AddScoped<INotificationService, SmsNotificationService>(), both registrations are valid. Resolving INotificationService from the container returns the last registration โ SmsNotificationService. The email implementation is effectively hidden.
This surprises developers because it is different from how most other DI containers behave, and it is different from what you might expect if you have worked with configuration overriding.
Where this bites teams:
- Integration tests that register a mock implementation after the real one (expecting to override) find that the mock is resolved correctly โ but only if test setup registers last. If the production registration somehow runs after the test setup, the real implementation wins.
- Modules or plugins that add implementations to an existing interface without knowing about prior registrations can silently replace behaviour.
- Resolving
IEnumerable<INotificationService>correctly returns all implementations in registration order. Teams that expectINotificationService(singular) to give them all implementations are surprised when only the last one is returned.
The fix: When you intend to have multiple implementations, design for it explicitly. Use IEnumerable<T> injection when a consumer needs all implementations. Use named/keyed services (available natively in .NET 8+ via AddKeyedScoped) when different consumers need different implementations. Never rely on implicit registration order to control which implementation is resolved โ make the selection explicit.
Mistake 7: Constructing Dependencies Outside the DI Container
This is the mistake that looks harmless in isolation: var service = new MyService(new MyRepository(new AppDbContext(options))). It compiles. It runs. It works in unit tests. But it breaks the DI system in ways that accumulate.
What goes wrong:
- The constructed object and its entire dependency tree are invisible to the DI container. No lifetime management. No scope boundary. No disposal by the framework.
- If
MyServiceis later refactored to depend on a new service, every call site where it is manually constructed must be updated. Constructor injection centralises this โnewscatters it. - Factories, decorators, and pipeline behaviours registered in the DI container do not apply to manually constructed objects. An object created with
newbypasses middleware, logging behaviour, and any cross-cutting concern wired through the container.
In production ASP.NET Core code, manually constructing services is rarely justified. The cases where it is legitimate: creating simple, truly static value objects (records, configuration structs) that have no runtime dependencies, and constructing test doubles in unit test arrangement code where DI adds no value.
The fix: Register everything in the DI container. If a service needs to be created dynamically at runtime (based on a type identifier, a feature flag, or runtime data), use a factory โ but register the factory itself in the container and let it resolve its dependencies through the container.
How to Audit Your Codebase for DI Mistakes
Running ValidateScopes = true and ValidateOnBuild = true in development (which is the default behaviour in ASP.NET Core's Development environment) will catch captive dependencies at application startup. This is the first and most valuable automated check.
Beyond that, a targeted code review for:
- Any class that takes
IServiceProvideras a constructor parameter (service locator) - Any
IDisposableservice registered as transient - Any singleton that holds a reference to
HttpContextorIHttpContextAccessor - Any
new ServiceType(...)outside of test setup code
This is not a comprehensive list, but it covers the most common sources of DI-related production failures.
DI in ASP.NET Core is powerful precisely because it handles object lifetime, disposal, and dependency resolution automatically โ but only for objects registered in the container. Every workaround to that system trades short-term convenience for long-term instability.
โ Prefer a one-time tip? Buy us a coffee โ every bit helps keep the content coming!
Frequently Asked Questions
What is a captive dependency in ASP.NET Core DI?
A captive dependency occurs when a longer-lived service (typically a singleton) holds a reference to a shorter-lived service (scoped or transient). The shorter-lived service is never released because the singleton's lifetime controls when it is disposed. This leads to stale data, thread-safety violations, and memory leaks. The standard fix is to inject IServiceScopeFactory into the singleton and create an explicit scope when the scoped service is needed.
Why should I avoid the service locator pattern in ASP.NET Core?
The service locator pattern uses IServiceProvider.GetService<T>() to resolve dependencies at call time rather than declaring them as constructor parameters. While it works, it hides dependencies, makes unit testing significantly harder (you must mock the entire service provider), and causes misconfiguration errors to surface at runtime rather than at startup. Constructor injection is always preferred except in infrastructure code like middleware or application bootstrapping.
Can I register multiple implementations of the same interface in ASP.NET Core DI?
Yes. Registering multiple implementations of the same interface is supported. However, resolving the interface by type (e.g., INotificationService) returns only the last registered implementation. To access all implementations, inject IEnumerable<INotificationService>. To direct different consumers to different implementations, use keyed services (introduced natively in .NET 8 via AddKeyedScoped, AddKeyedSingleton, etc.).
What happens if a transient service implements IDisposable in ASP.NET Core?
The DI container tracks disposable transient services and calls Dispose() on them when the enclosing scope ends. This is correct behaviour within a request scope. The problem arises when disposable transients are resolved from the root container (outside any request scope) โ the root container holds them for the application's entire lifetime, causing a memory leak. Disposable transients should always be resolved within a properly scoped context.
How do I validate my DI registrations at startup in ASP.NET Core?
ASP.NET Core validates scope violations and resolves all services on build in the Development environment by default. You can explicitly enable this in any environment by setting ValidateScopes = true and ValidateOnBuild = true on the host builder's UseDefaultServiceProvider call. This catches captive dependencies (scoped services injected into singletons) at startup rather than at runtime during a request.
What is the difference between Scoped and Transient service lifetimes?
Scoped services are created once per HTTP request (or per manually created IServiceScope) and shared across all resolutions within that scope. Transient services are created fresh every time they are resolved โ even multiple times within the same request. Scoped is appropriate for services that hold per-request state (like a DbContext or a current user service). Transient is appropriate for lightweight, stateless services where a new instance per use is safe and desirable.
Should I default new services to Scoped, Transient, or Singleton? For most application services in ASP.NET Core APIs, scoped is the safest default. It gives each request its own instance (avoiding thread-safety concerns) while limiting allocation to once per request (avoiding the overhead of transient). Use singleton only for provably thread-safe, stateless services. Use transient for very lightweight utility objects where per-resolution creation is genuinely needed. When in doubt, start with scoped and optimise with measurement.





