Skip to main content

Demystifying Dependency Injection in .NET: Patterns, Containers, and Best Practices

If you have worked on a .NET project of any size, you have likely encountered dependency injection (DI). The pattern is everywhere—from ASP.NET Core's built-in container to third-party libraries like Autofac and Unity. But knowing how to use DI well is different from knowing how to abuse it. In this guide, we focus on practical decisions: which injection pattern to choose, how to configure the container without overcomplicating things, and—most importantly—when to step back and reconsider whether DI is the right tool for the job. Where Dependency Injection Shows Up in Real .NET Work Dependency injection is not an academic concept; it appears in nearly every layer of a .NET application. In ASP.NET Core, the framework itself pushes DI into controllers, middleware, and filters. A typical startup class injects IConfiguration , ILogger , and database contexts.

If you have worked on a .NET project of any size, you have likely encountered dependency injection (DI). The pattern is everywhere—from ASP.NET Core's built-in container to third-party libraries like Autofac and Unity. But knowing how to use DI well is different from knowing how to abuse it. In this guide, we focus on practical decisions: which injection pattern to choose, how to configure the container without overcomplicating things, and—most importantly—when to step back and reconsider whether DI is the right tool for the job.

Where Dependency Injection Shows Up in Real .NET Work

Dependency injection is not an academic concept; it appears in nearly every layer of a .NET application. In ASP.NET Core, the framework itself pushes DI into controllers, middleware, and filters. A typical startup class injects IConfiguration, ILogger, and database contexts. Behind the scenes, the framework resolves these dependencies automatically, so you rarely think about it—until something breaks.

Consider a simple web API endpoint that fetches user data. Without DI, the controller might directly instantiate a UserRepository and a DatabaseConnection. That works for a demo, but in production you quickly run into problems: unit testing becomes difficult because you cannot swap out the real database; changing the connection string means editing code; and every class that needs a repository must know how to construct it. DI solves these issues by inverting control: instead of a class creating its own dependencies, an external container provides them.

The built-in DI container in Microsoft.Extensions.DependencyInjection (introduced in .NET Core 1.0) is now the default for most new projects. It supports three lifetime scopes—transient, scoped, and singleton—and can resolve dependencies through constructor injection, which is the most common pattern. However, the container is intentionally simple; it lacks advanced features like property injection, interception, or named registrations. For those, teams often turn to third-party containers, but that decision comes with trade-offs we will explore later.

In enterprise applications, DI is not limited to web controllers. Background services, console apps, and even unit tests benefit from the same pattern. A typical scenario: a WorkerService that processes messages from a queue. The service might depend on ILogger, IMessageProcessor, and IDataStore. With DI, you can register these dependencies in the host builder and let the container wire everything together. Testing becomes straightforward—you can mock IMessageProcessor and verify the worker handles messages correctly.

Despite its prevalence, DI is often misunderstood. Many developers treat it as a magic wand that automatically makes code testable and maintainable. In reality, DI is a tool that must be applied with care. When overused, it can lead to complex configuration, performance overhead from container resolution, and code that is harder to follow because dependencies are hidden. The key is to recognize where DI adds value and where it adds friction.

Foundations That Developers Often Confuse

Three concepts are frequently mixed up: dependency injection, inversion of control (IoC), and the service locator pattern. Understanding the differences is crucial because each has distinct implications for your code.

Inversion of control is a broad principle where the framework controls the flow of the program. In .NET, ASP.NET Core's middleware pipeline is an example: the framework calls your code, not the other way around. DI is one way to achieve IoC, but not the only way. Event-driven programming and template method patterns also invert control.

Dependency injection is a specific technique: a class receives its dependencies from an external source rather than creating them internally. The most common form is constructor injection, where dependencies are passed as parameters to the constructor. This makes the class's dependencies explicit and easy to replace. For example:

public class OrderService {
    private readonly IOrderRepository _repo;
    public OrderService(IOrderRepository repo) {
        _repo = repo;
    }
}

The OrderService does not know how to create a repository; it simply uses one. This is clean and testable.

Service locator is an anti-pattern that many developers mistakenly think is DI. With a service locator, a class asks a central registry for its dependencies at runtime. For instance:

public class OrderService {
    public void Process() {
        var repo = ServiceLocator.Get<IOrderRepository>();
    }
}

This looks similar, but it hides dependencies: you cannot tell from the constructor or method signature what the class needs. It also makes testing harder because the locator must be configured before each test. The built-in IServiceProvider can be used as a service locator, but doing so is generally discouraged. The same goes for the GetService method inside controllers—it is a code smell that often indicates a design problem.

Another foundational concept is lifetime management. Every dependency you register has a lifetime: transient (a new instance every time), scoped (one instance per request or scope), or singleton (one instance for the entire application). Choosing the wrong lifetime can cause subtle bugs. For example, registering a scoped service as a singleton can lead to stale data across requests. Conversely, registering a singleton as transient can waste memory and cause unexpected behavior if the service holds state. The rule of thumb: stateless services can be singletons; services that hold request-specific state should be scoped; and lightweight stateless services that are expensive to create can be transient.

Finally, many developers confuse the DI container with the DI pattern itself. The container is just a convenience tool; you can practice DI without any container at all, by manually wiring dependencies in composition root. In small projects, that is often simpler. The container shines when the dependency graph grows large and complex.

Patterns That Usually Work

Three injection patterns dominate .NET: constructor injection, method injection, and property injection. Each has its place, but constructor injection is the default choice for most scenarios.

Constructor Injection

This is the pattern used by the built-in container. Dependencies are passed as constructor parameters, and the container resolves them automatically. The advantages are clear: dependencies are explicit, the class is immutable after construction, and testing is straightforward—you simply pass mocks to the constructor. Use this pattern for mandatory dependencies that the class needs to function. For example, a PaymentGateway that requires an ILogger and an IPaymentProcessor should receive them via constructor.

One common mistake is having too many constructor parameters. When a constructor takes more than three or four dependencies, it is a sign that the class is doing too much. Refactor by grouping related dependencies into a single object (e.g., a PaymentOptions class) or splitting the class into smaller services. Another pitfall is circular dependencies: if ServiceA depends on ServiceB and ServiceB depends on ServiceA, the container will throw an exception at runtime. This indicates a design flaw that should be resolved by introducing an interface or event-based communication.

Method Injection

Sometimes a dependency is only needed for a single method, not the entire lifetime of the object. In that case, pass the dependency as a method parameter. A typical example is a FileProcessor that needs a ILogger only for the Process method. Method injection keeps the constructor clean and makes it obvious that the dependency is optional or context-specific. This pattern is also used in ASP.NET Core middleware, where the Invoke method receives dependencies that are not needed by the middleware constructor.

Property Injection

Property injection sets dependencies via public properties after the object is constructed. The built-in container does not support this directly; you need a third-party container or manual wiring. Use property injection for optional dependencies that have sensible defaults. For example, a ReportGenerator might have a FooterFormatter property that defaults to a simple formatter, but can be overridden. However, property injection makes dependencies less explicit and can lead to null reference exceptions if the property is not set. In general, prefer constructor injection for required dependencies and method injection for per-call dependencies.

A fourth pattern, ambient context, is sometimes mentioned but rarely recommended. It uses a static property to provide a default implementation that can be overridden per thread. This pattern hides dependencies and makes testing difficult; we advise against it.

Anti-Patterns and Why Teams Revert

Even experienced teams fall into traps that turn DI from a benefit into a burden. Here are the most common anti-patterns and why they cause teams to abandon DI.

Service Locator

We already touched on this. When developers find constructor injection tedious, they sometimes resort to a static service locator. The immediate appeal is convenience—you can resolve any dependency anywhere. But the cost is high: the code becomes opaque, unit tests require global setup, and the locator becomes a single point of failure. Teams that use service locators often revert to manual instantiation because they cannot manage the complexity.

Captive Dependencies

A captive dependency occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime. For example, a singleton service that injects a scoped DbContext. The singleton is created once and holds the DbContext for the entire application lifetime, which means the context is never disposed and becomes stale. This is a common bug in ASP.NET Core applications that use singleton services with scoped dependencies. The fix is to either make the service scoped or use the IServiceScopeFactory to create a new scope when needed.

Constructor Over-Injection

When a constructor has too many parameters (more than four or five), the class likely violates the Single Responsibility Principle. Developers may try to fix this by injecting a single IServiceProvider and resolving dependencies inside methods—effectively turning the class into a service locator. Instead, refactor the class into smaller, focused services that each have a clear responsibility.

Concrete Class Registrations

Some teams register concrete classes instead of interfaces, thinking it simplifies the container configuration. While this works, it couples the consumer to a specific implementation and makes it harder to swap in mocks or alternative implementations. Always program to an interface or abstract class, even if you only have one implementation. The small upfront cost pays off when you need to add logging, caching, or a mock for testing.

Overusing the Container

Not everything needs to be in the container. Data transfer objects (DTOs), primitive values, and simple value objects should be created manually. Pushing them through the container adds unnecessary overhead and obscures the fact that they are just data. A common example is injecting IConfiguration into every service; instead, use the options pattern to inject strongly typed settings.

Maintenance, Drift, and Long-Term Costs

DI containers are not set-and-forget. Over time, the composition root—the place where you register all dependencies—tends to grow and become tangled. Without discipline, the ConfigureServices method in ASP.NET Core can balloon into hundreds of lines. This is known as composition root drift. To keep it manageable, group registrations by feature or layer using extension methods. For example, create AddRepositories, AddServices, and AddExternalDependencies extension methods on IServiceCollection.

Another long-term cost is container performance. Resolution time is usually negligible, but if you register thousands of services, startup time can increase. Third-party containers like Autofac offer faster resolution and more features, but they add a dependency and may complicate the build. The built-in container is sufficient for the vast majority of projects; only switch if you have measured a performance problem or need a specific feature like property injection.

Testing also incurs a maintenance cost. While DI makes unit testing easier, integration tests that use the real container can be slow and brittle. A common strategy is to use a lightweight test container that registers only the services needed for the test, but keeping that in sync with the production composition root requires discipline. Consider using a dedicated test startup class or a test helper that mirrors the production registrations.

Finally, DI can obscure the flow of the application. When you see _repo.GetAll() in a method, you cannot tell what concrete repository is being used without inspecting the container configuration. This indirection is intentional, but it can make debugging harder. Tools like the Microsoft.Extensions.DependencyInjection analyzers and logging can help, but the cognitive overhead is real. Teams that overuse DI sometimes revert to simpler patterns like factory methods or manual DI to regain clarity.

When Not to Use This Approach

DI is not a universal solution. There are clear cases where it adds complexity without commensurate benefit.

Small or Short-Lived Projects

If you are building a console utility that runs once and does not need testing, manual instantiation is faster and simpler. The overhead of setting up a container, registering services, and managing lifetimes is not justified. Similarly, for prototypes or proof-of-concept code, avoid DI until you have validated the idea.

Performance-Critical Paths

In hot paths where every millisecond counts, the overhead of container resolution can be significant. For example, a custom serializer that is called millions of times should not resolve dependencies through the container. Instead, use direct instantiation or a factory pattern that caches instances. The container is designed for composition at startup, not for high-frequency resolution.

Simple Composition

When a class has only one or two dependencies that never change, DI is overkill. For example, a MathHelper that depends only on ILogger can be instantiated manually in the composition root without a container. The container becomes useful when the dependency graph has multiple levels and many branches.

Legacy Code Migration

Introducing DI into a large legacy codebase can be disruptive. If the existing code uses static classes, singletons, or tight coupling, a gradual approach is better. Start by extracting interfaces for the most volatile dependencies and injecting them manually at the call sites. Over time, you can introduce a container for new features. Forcing a full container adoption often leads to resistance and reverts.

Open Questions / FAQ

Here are answers to common questions that arise when applying DI in .NET.

Should I use the built-in container or a third-party one?

The built-in container meets most needs. It is fast, well-integrated, and has no external dependencies. Use a third-party container (like Autofac or Simple Injector) if you need advanced features such as property injection, interception, or convention-based registration. However, weigh the added complexity and dependency. Many teams find the built-in container sufficient even for large projects.

How do I handle dynamic resolution?

Sometimes you need to resolve a service based on a runtime value, such as a user-selected provider. The recommended approach is to use a factory pattern: inject a Func<string, IService> or an IServiceFactory that maps keys to implementations. Avoid injecting IServiceProvider directly, as that turns your class into a service locator.

What about disposing services?

The container automatically disposes of services that implement IDisposable when their lifetime ends (e.g., at the end of a scope for scoped services). However, if you manually resolve a service from the container, you are responsible for disposing it. A common mistake is to resolve a service in a using block, which disposes it prematurely; let the container manage the lifetime.

Can I use DI with static classes?

No. DI works with instance classes. If you have static classes that need dependencies, consider converting them to instance classes. Alternatively, use the options pattern to pass configuration, but avoid injecting services into static methods.

Summary and Next Experiments

Dependency injection is a powerful pattern that, when applied with intention, improves testability, maintainability, and flexibility. The key takeaways are: use constructor injection for mandatory dependencies, prefer the built-in container unless you have a specific need, avoid service locators and captive dependencies, and keep your composition root organized. Not every project needs a container; for small or performance-critical code, manual wiring is often better.

To deepen your understanding, try these experiments in your next .NET project:

  • Refactor a tightly coupled class to use constructor injection without a container, then introduce the built-in container to see the difference.
  • Add a unit test project and mock every dependency of a service that uses DI. Compare the effort to testing a class that uses service locator.
  • Monitor the startup time of your application with the built-in container and then with a third-party container. Measure the difference and decide if the trade-off is worth it.
  • Review your composition root and group registrations by feature using extension methods. Track how that affects readability and maintenance.

Share this article:

Comments (0)

No comments yet. Be the first to comment!