Skip to main content

Why Dependency Injection in .NET Modernization Is an Architecture Decision, Not a Testing Tool

Most teams treat dependency injection as a way to make unit tests easier. They introduce an IoC container, wire up their constructors, and call it modernization. But when I worked through a legacy .NET modernization that moved monolithic services into testable, microservice-ready components, dependency injection turned out to be the single most important architectural lever—not because it made mocking easier, but because it forced us to name, own, and version the contracts between components that had been tangled together for years.

The question was not whether to use dependency injection. The question was what boundary to inject across first, and how to do it without stopping feature delivery or introducing runtime failures that only appeared in production.

The Boundary Problem Dependency Injection Surfaces

Legacy .NET systems—especially those built before .NET Core and the rise of built-in DI containers—often mix construction, configuration, and execution in the same method. A service that sends emails might instantiate an SMTP client, read connection strings from configuration files, construct a message, log the attempt, handle retries, and update a database record all in a single 300-line procedure. Testing that procedure means either running a full integration test with live dependencies or giving up.

Dependency injection does not fix that code. But introducing it forces you to answer a question the legacy code avoids: what does this component actually depend on? When you try to inject an email sender, you realize it depends on configuration, logging, retry logic, and persistence. Those dependencies were always there. Dependency injection makes them explicit, named, and therefore negotiable.

In the .NET 6 modernization I worked on, we started by extracting interfaces for external systems—not because we wanted to mock them, but because we needed to replace WCF service clients with REST API calls without rewriting every consumer at once. Defining an interface such as ICustomerDataService let us run the old WCF implementation and a new HTTP client side by side, routing traffic based on feature flags. Dependency injection was the mechanism that made that routing possible without littering the code with factory methods and conditional instantiation.

Choosing the First Interface to Extract

The first interface you extract sets the pattern for the rest of the migration. Choose poorly and you create an abstraction that leaks implementation details, couples unrelated concerns, or becomes impossible to replace without breaking every caller.

We chose external service calls as the first extraction target for three reasons:

  • Clear failure boundary: External services fail independently of application logic, so isolating them behind an interface makes failure handling explicit.
  • Testable without mocks: You can write a fake implementation that returns canned responses, which is faster and more reliable than configuring a mock framework for every test case.
  • Versionable contract: External services change. An interface gives you a place to version those changes and migrate callers incrementally.

We avoided extracting persistence logic first, even though it was tightly coupled, because the database schema and stored procedures were shared across multiple services. Defining a repository interface would have frozen a data access pattern we knew we needed to change. External service calls were safer because we controlled both sides of the interface during the migration.

Dependency Injection Without a Big-Bang Container Migration

Legacy .NET systems often do not have a composition root. Services are instantiated inline, configuration is read from static classes, and the call graph is implicit. Introducing a DI container—whether the built-in .NET Core container, Autofac, or another—requires a composition root, which means refactoring how the application starts.

We avoided a big-bang migration by introducing dependency injection at the seam between old and new code. The legacy monolith continued to instantiate dependencies manually. New microservices and extracted components used constructor injection and the built-in .NET container. At the boundary, we wrote adapter classes that instantiated legacy dependencies and passed them to new components.

Here is a simplified example of the pattern:

public class LegacyOrderProcessor
{
    public void ProcessOrder(int orderId)
    {
        var emailSender = new SmtpEmailSender();
        var customerService = new WcfCustomerService();
        
        // legacy processing logic
    }
}

public class ModernOrderProcessor
{
    private readonly IEmailSender _emailSender;
    private readonly ICustomerService _customerService;

    public ModernOrderProcessor(IEmailSender emailSender, ICustomerService customerService)
    {
        _emailSender = emailSender;
        _customerService = customerService;
    }

    public void ProcessOrder(int orderId)
    {
        // new processing logic using injected dependencies
    }
}

public class OrderProcessorAdapter
{
    public void ProcessOrder(int orderId, bool useModernPath)
    {
        if (useModernPath)
        {
            var emailSender = new SmtpEmailSender(); // will be replaced
            var customerService = new WcfCustomerService(); // will be replaced
            var processor = new ModernOrderProcessor(emailSender, customerService);
            processor.ProcessOrder(orderId);
        }
        else
        {
            var processor = new LegacyOrderProcessor();
            processor.ProcessOrder(orderId);
        }
    }
}

The adapter is not elegant. It still instantiates dependencies manually. But it creates a boundary where the new code uses constructor injection and the old code does not. Once the new path proved stable, we moved the adapter into a proper composition root and registered dependencies in the DI container. The legacy path was deleted, and the adapter became unnecessary.

What Dependency Injection Unlocked Beyond Testing

Constructor injection made it possible to swap implementations without changing calling code. That was critical for three migration scenarios:

Replacing WCF with REST: We defined an interface for customer data retrieval, implemented it first with a WCF client, then added an HTTP client implementation. Feature flags controlled which implementation the container resolved. Callers never knew which transport they were using.

Moving configuration out of web.config: Legacy services read connection strings and API keys from XML configuration files. We introduced an IConfigurationProvider interface, implemented it first as a wrapper around ConfigurationManager, then replaced it with an implementation that read from environment variables and AWS Secrets Manager. The migration happened one service at a time, with no code changes to consumers.

Adding observability: Legacy code logged to local files using static log classes. We introduced an ILogger interface, implemented it as a wrapper around the legacy logger, then added structured logging to CloudWatch without changing any logging call sites. Dependency injection made it possible to decorate the logger with correlation IDs, request context, and performance metrics without touching business logic.

When Dependency Injection Becomes a Liability

Dependency injection introduces runtime failures that do not exist in code that instantiates dependencies inline. If the container is misconfigured—if a service is registered with the wrong lifetime, or a required dependency is not registered—the application fails at runtime, not at compile time.

We mitigated this risk in two ways. First, we wrote integration tests that instantiated the DI container and resolved every registered service. If a dependency graph was broken, the test failed before deployment. Second, we kept dependency graphs shallow. Services depended on interfaces, not on other services that themselves had deep dependency trees. Deep graphs make it hard to reason about service lifetimes and increase the risk of circular dependencies.

We also avoided constructor over-injection. If a constructor required more than four or five dependencies, it was a signal that the class had too many responsibilities. In those cases, we split the class or introduced a facade that aggregated related dependencies behind a single interface.

Designing Dependency Lifetimes for Microservices

In a monolith, dependency lifetime is mostly about memory and thread safety. In a microservice architecture, lifetime is about state, idempotency, and failure isolation. A singleton service that caches state across requests can cause race conditions or stale data. A transient service that opens a database connection on every request can exhaust connection pools.

We used three lifetime patterns:

  • Singleton for stateless services and shared infrastructure: HTTP clients, configuration providers, and logging services were registered as singletons. They held no per-request state and were thread-safe.
  • Scoped for request-specific services: Services that tracked request context, correlation IDs, or user identity were scoped to the HTTP request or Lambda invocation. They were disposed automatically at the end of the request.
  • Transient for services that held mutable state: Rare, but used for services that needed a fresh instance on every resolution to avoid accidental state sharing.

Choosing the wrong lifetime caused production issues. We once registered a REST client as transient, which meant every API call created a new HttpClient instance. Under load, the service exhausted available sockets and started failing with connection timeouts. Switching to a singleton resolved the issue immediately.

A Migration Checklist for Dependency Injection in Legacy .NET

Introducing dependency injection into a legacy .NET system is not a refactoring exercise. It is an architecture decision that changes how services are composed, tested, and deployed. Based on the modernization work I have done, here is a checklist for making that decision well:

  1. Start with external dependencies—service clients, API wrappers, configuration providers—not internal business logic.
  2. Define interfaces that represent contracts, not implementation details. Avoid leaking database schemas, HTTP headers, or serialization formats into the interface.
  3. Introduce dependency injection at the seam between old and new code. Do not try to refactor the entire codebase at once.
  4. Write integration tests that resolve every registered service from the container. Catch misconfiguration before deployment.
  5. Keep dependency graphs shallow. If a constructor has more than five dependencies, split the class.
  6. Choose service lifetimes based on state, thread safety, and resource pooling. Test under load to verify the choice.
  7. Use feature flags to route traffic between old and new implementations. Dependency injection makes that routing possible without conditional logic in every method.

Dependency injection is not a testing convenience. It is the architectural lever that makes legacy .NET systems modular, replaceable, and safe to change. If you are modernizing a monolith into microservices, or migrating from WCF to REST, or introducing observability into a legacy codebase, dependency injection is where that work starts—not where it ends.

Comments