.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 to EmailSender. What if you want to send an SMS instead? You'd have to change the NotificationService class.
  • Hard to Test: How do you unit test NotificationService without actually sending an email? It's difficult because you can't easily replace EmailSender 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.

  1. Transient (AddTransient)

    • A new instance is created every time it is requested.
    • Use Case: Lightweight, stateless services.
  2. 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.
  3. 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 the NotificationService 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 implements IMessageSender and change one line in Program.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.