Modern .NET Development
Dependency Injection in .NET: Practical Patterns and Common Mistakes
The .NET container is intentionally simple. Most DI failures come not from registration syntax but from misunderstood lifetimes, hidden resolution, expensive construction and object graphs that reveal confused ownership.

Dependency injection is an ownership model
Constructor injection makes a class's collaborators visible and lets the composition root choose implementations. The container also owns creation and disposal, which means every registration makes a lifetime promise. A type is not merely “available through DI”; it is shared or recreated according to rules that affect thread safety, state and resources.
When the graph becomes difficult to construct, adding factories or service location may hide the symptom. First ask whether the class has too many responsibilities, whether the dependency belongs to this operation, and which component should own its lifetime.
The three lifetimes are not performance settings
| Lifetime | Meaning | Typical fit |
|---|---|---|
| Transient | New instance each resolution | Small stateless collaborators |
| Scoped | One instance per scope | Request or unit-of-work state such as EF contexts |
| Singleton | One instance for the root container | Thread-safe shared services and immutable state |
Transient does not guarantee immediate disposal if a disposable transient is resolved from the root; the container may retain it until shutdown. Singleton does not mean “efficient by default”; it means every caller shares state concurrently. Scoped means one per DI scope, not necessarily one per HTTP request in background workers or manually created scopes.

Captive dependencies are stale state in disguise
A singleton that directly captures a scoped service effectively promotes that instance to singleton use. Scope validation catches common cases in development, but factories and service location can conceal them. The resulting bugs include reused EF contexts, cross-request state, disposed objects and concurrent access to non-thread-safe services.
The normal dependency direction is that a shorter-lived object may depend on a longer-lived one. If a singleton background coordinator needs scoped work, inject IServiceScopeFactory, create a scope per unit of work, resolve the scoped handler inside it, await completion and dispose the scope. Do not cache the resolved scoped service.
Creating a scope is an ownership boundary, not a way to make any resolution compile. Keep it in infrastructure such as hosted-service loops or message pumps, not in domain services.
Singletons must be thread-safe and publication-safe
ASP.NET Core serves requests concurrently. Mutable fields in a singleton are shared across all of them. Even an apparently harmless cache dictionary needs concurrency, eviction and memory limits. Prefer immutable configuration and thread-safe collaborators.
A singleton depending on a transient receives one transient for the singleton's lifetime because construction occurs once. The registration label does not force repeated resolution inside an existing object. If per-operation instances are required, inject a typed factory with clear ownership.
Avoid blocking asynchronous work during singleton construction. Constructors cannot be asynchronous, and .Result or GetAwaiter().GetResult() can delay startup or deadlock in other hosts. Perform required warm-up in a hosted lifecycle component with readiness signalling, or lazily initialise with a carefully designed async path.
Factories should express variability, not expose the container
A factory is appropriate when callers select among runtime strategies, need a fresh owned instance, or cross a scope boundary. Inject Func<T>, a typed factory or keyed services where the selection is genuinely part of the design. Name the choice in domain or capability language.
Injecting IServiceProvider into ordinary services is the service-locator pattern. Dependencies disappear from constructors, missing registrations fail later, and tests must recreate container behaviour. A factory that simply wraps GetRequiredService for arbitrary types changes the spelling but not the problem.
Be explicit about disposal. If the container creates a disposable object, the container normally disposes it. If a factory creates it outside container ownership, the caller or factory must define who does.
Options have lifetimes too
IOptions<T> provides one value and is suitable for stable configuration. IOptionsSnapshot<T> is scoped and recomputes per scope; it cannot be injected into a singleton. IOptionsMonitor<T> is singleton-friendly and can observe change notifications.
Reloadability is a product decision, not a free feature. A changed timeout can apply to future calls, but a changed encryption key, database schema assumption or queue name may require coordinated migration. Validate options at startup for configuration required to run. Fail before taking traffic rather than on the first rare code path.
Do not inject one enormous configuration object everywhere. Group options around owned capabilities and keep secrets out of diagnostic string representations and logs.
HttpClient is a managed resource even when injected
The HTTP client factory creates logical HttpClient instances while pooling and rotating underlying handlers. Typed clients keep base addresses, serializers and policies close to one external dependency. Do not register a typed client as a singleton if it captures a factory-managed client in a way that defeats handler and DNS behaviour.
Delegating handlers are themselves part of the DI graph. They must not capture request-scoped state into a longer handler lifetime. Pass per-request data through the outgoing request or an appropriate accessor whose semantics are understood, and redact credentials from logging.
Resilience policies belong to operations. A retry handler applied to an entire client may repeat non-idempotent POST requests after uncertain outcomes. DI makes policies easy to attach; it does not determine whether they are correct.
DbContext scope is a unit-of-work choice
The common scoped EF Core context aligns with one HTTP request, but a request may contain more than one business transaction and background processing has no automatic request scope. Keep contexts short-lived, never share them across threads and create explicit scopes or use an IDbContextFactory<T> where operations need independent units of work.
Injecting a context into a singleton is wrong. Holding it in a long-running scoped worker can also grow tracking state and serve stale entities. Resolve per message or batch and dispose predictably.
Large constructors are useful feedback
Ten dependencies do not automatically mean a class is wrong, but they often reveal coordination across unrelated capabilities. Do not hide them behind a facade whose only purpose is reducing constructor parameters. Group dependencies only when the group represents a cohesive concept.
Primitive configuration values should arrive through focused options or value objects, not a series of anonymous strings. Multiple implementations should be selected through an explicit strategy, keyed registration or factory rather than IEnumerable<T> ordering assumptions.
Registration extensions should remain cohesive: AddPayments can register the payment boundary and validate its options. One assembly-scanning convention for every interface may register internal types accidentally and makes lifetime decisions invisible.
DI does not make code testable by itself
Constructor injection allows substitution, but a test with mocks for every collaborator can still assert implementation rather than behaviour. Use real stateless services and domain objects. Fake true external ports. Integration-test registrations and infrastructure so production does not become the first container-resolution test.
A composition test should build the host with production-like configuration, validate scopes and resolve important entry points. It catches missing registrations, circular dependencies and options failures. It does not prove a lazily resolved service-locator path works; another reason to keep dependencies explicit.
Common production failures
- A singleton captures
DbContextor request state. - A hosted service resolves scoped handlers from the root container.
- A disposable transient resolved from root lives until shutdown.
- Configuration is reloadable even though the subsystem cannot change safely.
- A singleton's mutable cache has no concurrency or memory policy.
- A factory hides arbitrary service location and unclear disposal.
- A blanket HTTP retry handler repeats non-idempotent work.
- Assembly scanning assigns one lifetime to types with different ownership.
- Startup succeeds although critical options are invalid.
A disciplined composition root
Keep lifetime decisions near registration and give major capability groups readable extension methods. Enable scope and build validation in appropriate environments, validate critical options at startup, and test host construction. Prefer constructor injection and explicit factories at genuine runtime boundaries.
The container should reveal architecture, not become the architecture. Business code remains understandable when it can be constructed without knowing that a global service provider exists.
Related C3 Software guides
Frequently asked questions
Can a singleton depend on a scoped service?
Not directly. Create an explicit scope per unit of work in an infrastructure coordinator and do not retain the scoped instance afterward.
Should every class have an interface?
No. Interfaces should express useful ports, substitution or ownership boundaries rather than exist solely for container registration.
When should I use IOptionsMonitor?
Use it when a long-lived service can safely react to configuration changes. Do not imply runtime reload for settings that require coordinated restart or migration.
Is injecting IServiceProvider always wrong?
It is legitimate in composition and scope-owning infrastructure. In ordinary application services it hides dependencies and should be replaced by explicit collaborators or a typed factory.
Make dependency lifetimes explicit
C3 Software Limited has built and supported business software in the UK since 2001. If DI registrations are causing stale state or fragile tests, talk to C3 Software about a focused .NET architecture review.