.NET Dependency Injection Explained: A Practical Guide
A practical guide to understanding and using the built-in dependency injection container in .NET, covering service lifetimes, constructors, and best practices.
Dependency Injection (DI) is a core concept in modern software development and a first-class citizen in the .NET ecosystem. It's a design pattern that allows you to build loosely coupled, more testable, and more maintainable applications. Instead of a class creating its own dependencies, the dependencies are "injected" from an external source.
.NET has a powerful, built-in Inversion of Control (IoC) container that manages this process for you. Let's dive into how it works.
The Problem: Tight Coupling
Consider this example without DI:
public class NotificationService
{
private readonly EmailSender _emailSender;
public NotificationService()
{
// The NotificationService is responsible for creating its own dependency.
_emailSender = new EmailSender();
}
public void SendWelcomeEmail(string email)
{
_emailSender.Send(email, "Welcome!");
}
}
This code has several problems:
- Tight Coupling:
NotificationService
is permanently tied toEmailSender
. What if you want to send an SMS instead? You'd have to change theNotificationService
class. - Hard to Test: How do you unit test
NotificationService
without actually sending an email? It's difficult because you can't easily replaceEmailSender
with a mock or fake version.
The Solution: Dependency Injection
With DI, we invert the control. The class declares the dependencies it needs, and the DI container provides them.
First, we define an abstraction (an interface):
public interface IMessageSender
{
void Send(string recipient, string message);
}
public class EmailSender : IMessageSender
{
public void Send(string recipient, string message)
{
// Logic to send an email...
Console.WriteLine($"Email sent to {recipient}");
}
}
Now, we "inject" this dependency into the constructor of our service:
public class NotificationService
{
private readonly IMessageSender _messageSender;
// The dependency is provided to the constructor.
public NotificationService(IMessageSender messageSender)
{
_messageSender = messageSender;
}
public void SendWelcomeMessage(string email)
{
_messageSender.Send(email, "Welcome!");
}
}
NotificationService
no longer knows or cares about EmailSender
. It only knows about the IMessageSender
interface. This is loose coupling.
Registering Services in .NET
So, how does the container know to provide an EmailSender
when IMessageSender
is requested? You register it at application startup, typically in Program.cs
for modern .NET applications.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register the services
builder.Services.AddTransient<IMessageSender, EmailSender>();
builder.Services.AddScoped<NotificationService>();
var app = builder.Build();
// ... later, when a request comes in, the container can create NotificationService
This registration tells the DI container: "When a class asks for an IMessageSender
, create an instance of EmailSender
and provide it."
Understanding Service Lifetimes
When you register a service, you must specify its lifetime. This tells the container how long an instance of the service should live.
Transient (
AddTransient
)- A new instance is created every time it is requested.
- Use Case: Lightweight, stateless services.
Scoped (
AddScoped
)- A new instance is created once per client request (or scope). The same instance is shared within that single request.
- Use Case: This is the most common lifetime for web applications. It's perfect for services like a database context (
DbContext
) that should be shared throughout a single HTTP request but isolated between different requests.
Singleton (
AddSingleton
)- A single instance is created for the entire lifetime of the application. The same instance is shared across all requests.
- Use Case: Services that are expensive to create, are thread-safe, and hold global application state (e.g., a caching service or a configuration object).
Example Registration:
// A new logger factory is created for each class that needs it.
builder.Services.AddTransient<ILoggerFactory, MyLoggerFactory>();
// A single DbContext is shared within one HTTP request.
builder.Services.AddScoped<MyDbContext>();
// The same caching service instance is used by everyone.
builder.Services.AddSingleton<ICacheService, InMemoryCacheService>();
Benefits Revisited
- Testability: In a unit test, you can now easily create a mock version of
IMessageSender
and pass it to theNotificationService
constructor, giving you full control over the test. - Flexibility: Need to switch from sending emails to sending SMS messages? Just create a new
SmsSender
class that implementsIMessageSender
and change one line inProgram.cs
. No other code needs to change. - Maintainability: DI encourages you to build small, loosely coupled classes with single responsibilities, which are inherently easier to manage and refactor.
Conclusion
Dependency Injection is a fundamental pattern for building professional, enterprise-grade .NET applications. By letting the built-in IoC container manage your dependencies, you can focus on writing clean, testable, and maintainable business logic.