Dependency Injection in .NET: Why Your Code Needs It
- ShiftQuality Contributor
- Sep 13, 2025
- 4 min read
You have written .NET applications. You have classes that use other classes. Somewhere in your code, there is a new keyword creating a database connection, an HTTP client, or a service object. It works. It also welds your code to its dependencies in ways that make testing difficult, swapping implementations impossible, and changes expensive.
Dependency injection is the pattern that fixes this. Instead of a class creating the things it needs, those things are provided to it from the outside. The class declares what it requires. Something else — the DI container — figures out how to provide it.
This sounds like extra complexity for no benefit. It is not. It is the single most impactful pattern for writing .NET code that you can test, maintain, and evolve. And .NET has it built in, so the cost of adopting it is near zero.
The Problem Without DI
Consider a service that processes orders and sends confirmation emails:
public class OrderService
{
public void ProcessOrder(Order order)
{
var db = new SqlConnection("Server=prod;Database=orders;...");
// save order to database
var emailClient = new SmtpClient("smtp.company.com");
// send confirmation email
}
}
This class works. It also has three problems.
It cannot be tested without a real database and email server. Want to verify the order processing logic? You need a SQL Server instance and an SMTP server running. Your unit test is now an integration test, and it takes seconds instead of milliseconds.
It cannot be configured for different environments. The connection string and SMTP server are hardcoded. Development, staging, and production all need different values. You end up with conditionals, config files read inside the class, or worse — manual code changes per environment.
It cannot be reused in a different context. Need the same order processing logic but with a different email provider? You rewrite the class. Need to add logging? You modify the class. Every change to a dependency requires changing the service itself.
The root cause is the new keyword. When OrderService creates its own dependencies, it controls them — and it is controlled by them. It cannot function without those specific implementations.
The Fix
Dependency injection inverts this relationship. Instead of creating dependencies, the class receives them:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public OrderService(IOrderRepository repository, IEmailSender emailSender)
{
_repository = repository;
_emailSender = emailSender;
}
public async Task ProcessOrder(Order order)
{
await _repository.Save(order);
await _emailSender.SendConfirmation(order.CustomerEmail, order.Id);
}
}
Now OrderService depends on interfaces, not implementations. It does not know or care whether IOrderRepository is backed by SQL Server, PostgreSQL, or an in-memory list. It does not know whether IEmailSender uses SMTP, SendGrid, or a test double that records calls without sending anything.
Testing becomes trivial. Provide in-memory implementations. Call ProcessOrder. Verify the behavior. No database. No email server. Milliseconds.
Configuration becomes straightforward. Different environments wire different implementations — the production database in production, the test database in staging, the in-memory store in tests. The service code never changes.
.NET's Built-In Container
.NET includes a dependency injection container out of the box. You register your services at application startup, and the framework resolves them automatically when constructors request them.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
When a controller or another service needs OrderService, the container sees that it requires IOrderRepository and IEmailSender, creates the registered implementations, and passes them to the constructor. You never call new OrderService(...) yourself. The container handles the wiring.
Three lifetimes control how instances are managed:
Transient creates a new instance every time the service is requested. Use for lightweight, stateless services.
Scoped creates one instance per request. Use for database contexts, unit-of-work patterns, and anything that should be shared within a single HTTP request but isolated between requests.
Singleton creates one instance for the entire application lifetime. Use for configuration, caches, and stateless shared services.
Getting lifetimes wrong is the most common DI mistake in .NET. A singleton that depends on a scoped service captures a stale reference. A transient database context creates unnecessary connections. The rule is simple: a service's lifetime must be equal to or longer than the lifetimes of its dependencies.
When People Resist DI
The two common objections to DI are "it's too much abstraction" and "it's too many interfaces."
The abstraction objection misses the point. The abstraction exists to decouple, and decoupling exists to make the code testable and flexible. If you never test and never change implementations, the abstraction is waste. If you do either — and you will — the abstraction pays for itself immediately.
The interface objection has more merit. Not everything needs an interface. A pure data class does not need abstraction. A static utility function does not need abstraction. The guideline: create interfaces for services that have side effects (database, network, file system, email) or that you might want to swap (logging providers, payment processors, notification channels). Skip them for value objects, DTOs, and pure computation.
DI is a tool for managing dependencies on things that change or things that have side effects. Apply it there. Don't apply it everywhere.
The Takeaway
Dependency injection is not a pattern you adopt because someone told you to. It is the pattern that makes .NET code testable without infrastructure, configurable without conditionals, and flexible without rewrites.
The framework does the heavy lifting. You define interfaces for your external dependencies, register implementations at startup, and let constructor injection handle the wiring. The code is cleaner, the tests are faster, and the architecture supports change instead of resisting it.
Start with your next service. Define the interface. Inject the dependency. Write the test. The difference is immediate.
Next in the "Building Real .NET Apps" learning path: We'll cover Entity Framework Core — how to use .NET's ORM effectively, avoid the performance pitfalls, and keep your data access layer clean.



Comments