Dependency injection (DI) is a cornerstone of modern C# development, especially in ASP.NET Core applications. Yet, despite its widespread adoption, many teams still run into subtle bugs and maintenance headaches that stem from simple misconfigurations. A service registered with the wrong lifetime, a captive dependency that outlives its consumer, or an overstuffed constructor can turn a well-intentioned design into a fragile, hard-to-debug mess. This guide focuses on the most common DI missteps we see in real projects and offers concrete strategies to avoid them.
We assume you're already familiar with the basics of DI — registering services, injecting them via constructors, and the three standard lifetimes. Our goal is to help you move from 'it works' to 'it works reliably under load, is easy to test, and doesn't surprise you in production.' We'll cover the mechanics behind the scenes, walk through examples that fail in subtle ways, and give you a checklist to review your own codebase.
1. Why This Topic Matters Now
As C# applications grow in complexity — microservices, background workers, Blazor Server, and cloud-native patterns — the DI container becomes a critical piece of infrastructure. A single misconfigured service can cause cascading failures: memory leaks from long-lived scoped services, stale data from captured dependencies, or thread-safety issues when a singleton accidentally depends on a transient resource.
We've seen projects where a team spent days chasing a bug that turned out to be a scoped service registered as singleton, holding onto a database connection that was never meant to be shared. These problems are hard to reproduce locally because they often depend on concurrency or specific request patterns. The cost of fixing them after deployment is high — both in developer time and in production incidents.
Moreover, the built-in DI container in ASP.NET Core is intentionally simple. It doesn't warn you about many of these misconfigurations at startup; it only fails at runtime, often with cryptic exceptions like 'Cannot consume scoped service from singleton.' Understanding the underlying rules helps you write code that works correctly from the start and is easier to refactor later.
This topic also intersects with broader software design principles. Tight coupling between components makes unit testing harder, slows down development, and increases the risk of regressions. By mastering DI lifetimes and registration patterns, you're also improving the overall architecture of your application.
What's at Stake
Consider a typical e-commerce backend with services for orders, inventory, and payments. If the order service accidentally captures a scoped DbContext as a singleton, every request after the first will see stale data — or worse, throw exceptions when the context is disposed. Such bugs are notoriously hard to diagnose because they don't always manifest immediately. They might only appear under load or after a certain sequence of requests.
By understanding the common missteps, you can avoid these scenarios and build systems that are resilient, testable, and maintainable.
2. Core Idea in Plain Language
At its heart, dependency injection is about giving a class the things it needs (its dependencies) rather than having it create them itself. The DI container is a factory that manages the creation and disposal of those dependencies. The key insight is that the container controls the lifetime of each service — how long an instance lives and when it is reused.
In ASP.NET Core, there are three lifetimes:
- Transient: A new instance is created every time it's requested. Use for lightweight, stateless services.
- Scoped: One instance per scope (typically one HTTP request). Use for services that should be shared within a single operation, like a DbContext.
- Singleton: A single instance is created and shared for the entire application lifetime. Use for services that are thread-safe and hold no request-specific state.
The most common misstep is registering a service with a lifetime that doesn't match its intended use. For example, registering a service that depends on a scoped DbContext as a singleton will cause that DbContext to be captured for the lifetime of the application — leading to stale data and concurrency issues. This is known as a captive dependency.
Another frequent error is over-injection: a constructor that takes too many parameters. This is a symptom of a class doing too much, violating the Single Responsibility Principle. While DI itself doesn't cause this, it makes the problem visible. A constructor with six or more dependencies is a red flag that the class should be split into smaller, focused services.
The core idea is simple: match lifetimes to the natural scope of the dependency, and keep constructors lean. The rest is about understanding the mechanics and edge cases.
3. How It Works Under the Hood
To avoid missteps, it helps to understand what the container actually does when you call AddSingleton<IService, MyService>(). The container maintains a dictionary of service descriptors. Each descriptor contains the service type, the implementation type, and the lifetime. When a service is requested, the container checks its cache:
- For transient services, it always creates a new instance using the registered factory or constructor.
- For scoped services, it maintains a cache per scope. In ASP.NET Core, a scope is created per HTTP request. The container stores instances in a
Dictionary<Type, object>that lives for the duration of the scope. - For singleton services, the container holds a single instance in a static-like cache for the entire application lifetime.
When a service is resolved, the container also resolves its dependencies recursively. This is where lifetime mismatches become visible. If a singleton service depends on a scoped service, the container will capture that scoped instance at the time the singleton is first created — and hold it forever. The scoped service is no longer scoped; it becomes an effective singleton. This is the captive dependency problem.
The container does not validate these mismatches at registration time. It only throws an exception when it detects a scoped service being consumed from a singleton — but only if the scoped service is directly injected into the singleton's constructor. If the scoped service is injected deeper in the graph (e.g., a singleton depends on a transient that depends on a scoped), the container won't detect it at all. The bug silently corrupts your application state.
Another subtlety is disposal. The container tracks instances it creates and disposes them when their scope ends (for scoped) or when the application shuts down (for singletons). If you manually resolve a service from the container and don't dispose it, you might leak resources. The built-in container handles this for constructor-injected services, but if you use the service locator pattern (e.g., serviceProvider.GetService<T>()), you must ensure proper disposal.
Resolution Order and Circular Dependencies
The container resolves dependencies in a depth-first manner. If there's a circular dependency (A depends on B, B depends on A), the container throws an exception at runtime. This is another sign of poor design — break the cycle by introducing an interface or using a factory pattern.
4. Worked Example or Walkthrough
Let's walk through a concrete scenario that illustrates a common misstep. Imagine an ASP.NET Core application with an OrderService that processes orders and sends notifications. The OrderService depends on IOrderRepository (which uses Entity Framework's DbContext) and INotificationService.
Here's the registration code:
services.AddSingleton<IOrderRepository, OrderRepository>();
services.AddScoped<INotificationService, EmailNotificationService>();
services.AddSingleton<OrderService, OrderService>();At first glance, this looks fine. But OrderRepository depends on ApplicationDbContext, which is typically registered as scoped. Since OrderRepository is a singleton, it captures the DbContext for the entire application lifetime. The first request works fine, but subsequent requests reuse the same DbContext instance. This leads to:
- Stale data: The
DbContexttracks entities from the first request; later requests see outdated state. - Concurrency issues: Multiple requests share the same
DbContext, causing conflicts when saving changes. - Memory leaks: The
DbContextgrows as it tracks more entities over time.
To fix this, OrderRepository should be scoped, not singleton. The corrected registration:
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<INotificationService, EmailNotificationService>();
services.AddScoped<OrderService, OrderService>();Now each request gets its own OrderService and its own DbContext, avoiding the captive dependency.
Another example: Suppose you have a CacheService that is a singleton and depends on ITimeProvider to get the current time. If ITimeProvider is registered as transient, it's fine — the singleton captures the transient once, but since TimeProvider is stateless, there's no harm. However, if ITimeProvider is scoped and holds request-specific state (like a correlation ID), the singleton will capture the first request's correlation ID and reuse it forever — a subtle bug that's hard to trace.
The lesson: always check the lifetimes of transitive dependencies. A good practice is to mark services that depend on scoped resources as scoped themselves, unless you're certain they are stateless and thread-safe.
5. Edge Cases and Exceptions
Not every situation fits neatly into the three lifetimes. Here are some edge cases where the standard rules need adjustment.
Scoped Services in Background Tasks
Background tasks (e.g., IHostedService, BackgroundService) run outside an HTTP request scope. If they need a scoped service like DbContext, you must create a new scope manually:
using (var scope = serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// use dbContext
}Failing to create a scope will either throw an exception (if you try to resolve a scoped service from the root provider) or capture a singleton-like instance that lives forever. Always use IServiceScopeFactory to create scopes in long-running services.
Open Generics and Decorators
Registering open generics (e.g., IRepository<T>) is straightforward, but be careful with decorators. The built-in container doesn't natively support decorators; you need to manually chain registrations or use a third-party container like Autofac. A common misstep is registering both the base and decorated service with the same type, causing the container to return the last registered one. Use a factory delegate to wrap the inner service.
Multiple Implementations of the Same Interface
When you have multiple implementations of the same interface (e.g., IPaymentProcessor for different payment gateways), you can register them as a collection (services.AddSingleton<IPaymentProcessor, CreditCardProcessor>(); services.AddSingleton<IPaymentProcessor, PayPalProcessor>();) and inject IEnumerable<IPaymentProcessor> into the consumer. A misstep is trying to resolve a single instance when multiple are registered — the container will return the last one registered, which is often not what you want.
Lazy Resolution and Factories
Sometimes you need to resolve a service conditionally or defer its creation. The built-in container doesn't support Lazy<T> natively, but you can inject a factory delegate (Func<IService>) or use IServiceProvider directly. However, injecting IServiceProvider is a service locator anti-pattern and makes testing harder. Prefer explicit factory interfaces.
6. Limits of the Approach
While the built-in DI container in ASP.NET Core is sufficient for most applications, it has limitations that can lead to missteps if you push it too far.
No Automatic Lifetime Validation
The container does not warn you about captive dependencies or lifetime mismatches at startup. You only discover them at runtime, often through exceptions or incorrect behavior. Third-party containers like Autofac or StructureMap offer diagnostic features that detect these issues during registration. For critical applications, consider using a container with built-in analysis.
No Support for Interception or Decorators
If you need to add logging, caching, or validation around a service without modifying its code, you typically use decorators or interception. The built-in container requires manual chaining, which is error-prone. A common misstep is forgetting to register the inner service, causing a resolution failure. Using a container that supports decorators natively simplifies this.
Limited Support for Open Generics with Constraints
Registering open generics with constraints (e.g., IRepository<T> where T : class) works, but complex constraints may not be resolved correctly. The container uses a simple matching algorithm; if it fails, you'll get an exception at resolution time, not registration time.
Performance Overhead in High-Throughput Scenarios
For most applications, the performance of the built-in container is negligible. However, in extreme high-throughput scenarios (thousands of resolutions per second), the overhead of reflection-based activation can become noticeable. The container caches activation delegates after the first resolution, so the impact is usually limited to cold starts. If you need maximum performance, consider using compiled delegates or a lightweight container.
These limits don't mean you should avoid the built-in container; they just mean you need to be aware of them. For typical line-of-business applications, it works well. For complex, high-scale systems, evaluate whether a third-party container adds value.
7. Reader FAQ
How do I detect captive dependencies in my code?
The most reliable way is to review your composition root — the place where you register all services. For each singleton, check its constructor parameters and their lifetimes. If any parameter is scoped or transient, ask whether the singleton truly needs a new instance per request. Tools like the Microsoft.Extensions.DependencyInjection.Analyzers NuGet package can flag some issues at compile time. Third-party containers often provide runtime validation.
Can I mix lifetimes for the same interface?
Yes, you can register multiple implementations with different lifetimes, but the container will treat each registration independently. If you inject IEnumerable<IService>, you'll get all registered instances, each with its own lifetime. Be careful: if one implementation is scoped and another is singleton, the singleton will be shared across requests while the scoped one is recreated per request. This can lead to unexpected behavior if the implementations share state.
Should I always use constructor injection?
Constructor injection is the preferred method because it makes dependencies explicit and immutable. Property injection is sometimes used for optional dependencies, but it can lead to incomplete initialization. Method injection is rare in C# but useful for passing request-specific data. Avoid service locator (resolving from IServiceProvider directly) because it hides dependencies and makes testing harder.
How do I test classes that use DI?
DI makes testing easier because you can mock dependencies. In unit tests, you create instances of the class under test by passing mock objects directly — no container needed. For integration tests, you can set up a test container with real or fake implementations. The key is to keep constructors simple and avoid logic in the composition root.
What about performance? Does DI add overhead?
The overhead of resolving services is minimal for most applications. The built-in container caches activation delegates after the first resolution, so subsequent resolutions are fast. The main performance cost is in the registration phase (startup) and in the creation of transient services. If you have a high number of transient services, consider using pooled or scoped lifetimes where appropriate.
8. Practical Takeaways
By now, you should have a clear picture of the most common DI missteps and how to avoid them. Here are three specific actions you can take today to improve your codebase:
- Audit your composition root. Go through every service registration and verify that the lifetime matches the service's dependencies. For each singleton, trace its full dependency graph to ensure no scoped or transient services are captured unintentionally. Use a simple checklist: Is the service stateless? Is it thread-safe? Does it depend on request-scoped resources? If the answer to any of these is no, consider changing the lifetime to scoped.
- Adopt a convention for lifetime registration. Establish a team rule: services that depend on
DbContextor other scoped resources must be scoped themselves. Services that are pure utility (no state, no I/O) can be singleton. Transient is for lightweight, stateless services that are cheap to create. Document these conventions in your project's coding standards. - Set up compile-time or runtime validation. Add the
Microsoft.Extensions.DependencyInjection.Analyzerspackage to your project and treat its warnings as errors. For critical applications, consider integrating a third-party container with diagnostic capabilities during development. Run a startup validation that resolves all registered services to catch missing dependencies early.
Finally, remember that DI is a tool, not a goal. If a particular pattern makes your code harder to understand or maintain, it's okay to step back. Use DI where it adds value — decoupling, testability, and flexibility — and don't be afraid to use simpler approaches for small, stable components. The best DI configuration is the one that your team can reason about and change with confidence.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!