Dependency Injection and Inversion of Control for C# Developers
Dependency Injection (DI) and Inversion of Control (IoC) are terms that get thrown around a lot in .NET development. They show up in job postings, architecture discussions, and framework documentation. If you’ve ever felt like you sort of get it but not quite, this post is for you.
Let’s Define the Terms
Inversion of Control is a principle. It means your code does not decide what it depends on. Instead of a class creating its own dependencies, something else provides them. The “control” of object creation is inverted, moved outside the class that uses the dependency.
Dependency Injection is a technique to implement IoC. You pass dependencies into a class (usually through the constructor) rather than having the class create them internally.
These terms are often used interchangeably, and in day-to-day work that usually doesn’t cause problems. IoC is the idea, DI is how you do it. If someone says “we use IoC” or “we use DI”, they almost always mean the same thing in practice.
The Problem DI Solves
Consider code like this:
public class OrderService
{
public void PlaceOrder(Order order)
{
var repository = new SqlOrderRepository();
var emailService = new SmtpEmailService();
repository.Save(order);
emailService.SendConfirmation(order);
}
}
This looks simple, but it has problems:
- Hidden dependencies: You can’t tell from the constructor what this class needs.
- Hard to test: You can’t swap out the repository or email service without changing the code.
- Tight coupling:
OrderServiceis permanently tied toSqlOrderRepositoryandSmtpEmailService.
The common response is “just mock it”, but that’s not a design strategy. If your code is hard to test, the design is telling you something. Mocking frameworks can work around bad structure, but they don’t fix it.
Constructor Injection
Constructor injection should be your default. It means you declare dependencies as constructor parameters, and the class stores them for later use.
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
public OrderService(IOrderRepository repository, IEmailService emailService)
{
_repository = repository;
_emailService = emailService;
}
public void PlaceOrder(Order order)
{
_repository.Save(order);
_emailService.SendConfirmation(order);
}
}
A good constructor is complete and honest. Complete means the class has everything it needs after construction. Honest means the constructor signature tells you exactly what the class depends on.
Some anti-patterns to avoid:
- Optional dependencies: If a dependency is optional, the class probably has too many responsibilities.
- Service locators: Passing in a “resolver” that fetches dependencies hides what the class actually needs.
- Default constructors that create dependencies: This defeats the purpose entirely.
Composition Root and Object Graphs
The composition root is the single place in your application where you wire up all your dependencies. In a web app, this is typically Program.cs or Startup.cs. In a console app, it’s usually Main.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
Centralizing object creation has real benefits:
- You can see all your dependencies in one place.
- Application boundaries become clear.
- Startup behavior is explicit, not scattered across the codebase.
The rest of your code should not know or care how dependencies are created. It just receives them through constructors.
Lifetimes and Scope (Where Things Go Wrong)
In .NET’s built-in container, you have three lifetimes:
- Transient: A new instance every time it’s requested.
- Scoped: One instance per scope (usually per HTTP request in web apps).
- Singleton: One instance for the entire application lifetime.
services.AddTransient<ITransientService, TransientService>();
services.AddScoped<IScopedService, ScopedService>();
services.AddSingleton<ISingletonService, SingletonService>();
The captive dependency problem is the most common mistake. It happens when a singleton holds a reference to a scoped or transient service. The scoped service gets “captured” and lives as long as the singleton, which breaks the expected lifetime.
// Bad: Singleton capturing a scoped dependency
public class MySingleton
{
private readonly IScopedService _scoped; // This will outlive its intended scope
public MySingleton(IScopedService scoped)
{
_scoped = scoped;
}
}
This often works fine locally but breaks under load or in production. The scoped service might hold database connections or request-specific state that becomes stale or shared incorrectly.
Rule of thumb: a service can only depend on services with equal or longer lifetimes.
DI Containers: Tool, Not Architecture
A DI container does two things well:
- Resolution: Given a type, it figures out how to create an instance and all its dependencies.
- Lifetime management: It tracks when to create new instances and when to reuse existing ones.
That’s it. A container should not be the center of your architecture. If you find yourself using container-specific features heavily (decorators, interceptors, conditional registrations), step back and ask if the design is getting complicated.
Container-specific features are a smell. They tie your code to a particular container and often hide complexity that would be better addressed through simpler design.
Over-Injection and Constructor Bloat
If a class has ten constructor parameters, something is wrong. It’s not a DI problem, it’s a design problem.
// This is a sign of missing abstractions
public class OrderController
{
public OrderController(
IOrderRepository orders,
ICustomerRepository customers,
IInventoryService inventory,
IPaymentService payments,
IShippingService shipping,
IEmailService email,
ILogger logger,
IConfiguration config,
IMapper mapper,
IValidator validator)
{
// ...
}
}
When you see this, consider:
- Is this class doing too much? Maybe it’s orchestrating when it should be delegating.
- Are there missing abstractions? A facade or application service might group related operations.
- Is domain logic leaking into controllers? Controllers should be thin.
Refactoring strategies include introducing facades, splitting responsibilities, or moving orchestration into dedicated application services.
DI vs Service Locator (Don’t get carried away)
A service locator is a pattern where you ask a central registry for dependencies at runtime:
// Service Locator: Don't do this
public class OrderService
{
public void PlaceOrder(Order order)
{
var repository = ServiceLocator.Get<IOrderRepository>();
var emailService = ServiceLocator.Get<IEmailService>();
repository.Save(order);
emailService.SendConfirmation(order);
}
}
This breaks IoC because the class is still in control of getting its dependencies. The problems:
- Hidden dependencies: The constructor doesn’t tell you what the class needs.
- Harder to reason about: You have to read the entire class to understand its dependencies.
- Testing is awkward: You need to configure a global locator before tests run.
Service locators often hide behind innocent names like ServiceHelper, DependencyResolver, or factory classes that internally call a container. If you see code reaching into a container from business logic, that’s a service locator in disguise.
Testing Implications (Realistic, Not Idealized)
DI enables isolation, but it doesn’t guarantee good tests. You can have perfect DI and still write brittle, hard-to-maintain tests.
Some practical notes:
- Fakes are often better than mocks. A simple in-memory implementation of
IOrderRepositoryis easier to understand than a mock with complex setup. - Avoid using the container in unit tests. Just
newup the class with the dependencies it needs. If that’s painful, the design might need work. - Integration tests can use the real container. That’s fine and often valuable for testing the wiring itself.
// Unit test: no container needed
[Fact]
public void PlaceOrder_SavesOrder()
{
var fakeRepository = new FakeOrderRepository();
var fakeEmail = new FakeEmailService();
var service = new OrderService(fakeRepository, fakeEmail);
service.PlaceOrder(new Order());
Assert.Single(fakeRepository.SavedOrders);
}
DI in Modern .NET Specifically
The built-in container in .NET (Microsoft.Extensions.DependencyInjection) is intentionally simple. It handles constructor injection, lifetimes, and basic registration. It does not support:
- Property injection
- Named registrations
- Decorators
- Interception
These omissions are intentional. The philosophy is that most applications don’t need these features, and when they do, the design should be reconsidered first.
Libraries like Scrutor add convention-based registration (scanning assemblies for types to register). Libraries like Autofac add advanced features like decorators and modules. These can be valuable, but reach for them only when you have a clear need.
// Scrutor example: register all classes ending in "Service"
services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service")))
.AsImplementedInterfaces()
.WithScopedLifetime());
When DI Is Overkill
Not everything needs DI. For small utilities, scripts, or short-lived tools, the overhead of setting up a container and defining interfaces adds complexity without benefit.
Some heuristics:
- Single-file utilities: Just instantiate what you need directly.
- No tests planned: If you’re not going to test it, DI’s testability benefit doesn’t apply.
- Throwaway code: Prototypes and spikes don’t need architectural purity.
- Simple console apps: A 50-line tool doesn’t need a composition root.
Pragmatism beats purity. DI is a tool for managing complexity in larger systems. If the system isn’t complex, the tool might not be needed.
Practical Guidelines
If you take away a few things from this post:
- One composition root. Wire up dependencies in one place, at application startup.
- Constructor injection by default. It’s explicit, testable, and honest about dependencies.
- No service locator. If code reaches into a container from business logic, refactor it.
- Lifetimes chosen deliberately. Understand the difference between transient, scoped, and singleton. Avoid captive dependencies.
- Let design drive the container, not the other way around. If you’re fighting the container, the design might need attention.
DI and IoC are not magic. They’re organizational tools that help you build systems where dependencies are visible, testable, and easy to change. Use them where they help, skip them where they don’t.