Introduction
Dependency Injection (DI) is a fundamental software design pattern used to reduce coupling between classes and make applications easier to test, maintain, and extend.
In a tightly coupled application, a class creates the objects it depends on. For example, a Car class might directly create an Engine object. If the application later needs a different engine implementation, the Car class must be modified.
With Dependency Injection, the Car class declares that it needs an Engine, while another component is responsible for providing the required implementation.
.NET and ASP.NET Core provide a built-in Dependency Injection container, so developers can register services and let the framework create and provide their dependencies.
This article explains Dependency Injection, its relationship with Inversion of Control (IoC), service lifetimes, injection types, practical implementation, benefits, common anti-patterns, and recommended practices.
What Is Dependency Injection?
Dependency Injection is a design pattern in which an object receives the components it depends on from an external source instead of creating those components itself.
Consider a simple example:
public class Engine
{
public void Start()
{
Console.WriteLine("Engine started.");
}
}
public class Car
{
private readonly Engine _engine = new Engine();
public void Start()
{
_engine.Start();
}
}
The Car class creates its own Engine.
This creates a direct dependency between Car and the concrete Engine implementation. If the application needs a different engine implementation, the Car class has to change.
With Dependency Injection, the dependency can instead be supplied from outside the class.
public interface IEngine
{
void Start();
}
public class Engine : IEngine
{
public void Start()
{
Console.WriteLine("Engine started.");
}
}
public class Car
{
private readonly IEngine _engine;
public Car(IEngine engine)
{
_engine = engine;
}
public void Start()
{
_engine.Start();
}
}
Now Car depends on the IEngine abstraction rather than directly creating Engine.
This is the basic idea behind Dependency Injection:
The class declares what it needs; another component provides it.
Why Do We Need Dependency Injection?
Without DI, classes often create their own dependencies. This can lead to tight coupling.
For example:
public class EmailService
{
public void Send(string message)
{
Console.WriteLine(message);
}
}
public class OrderService
{
private readonly EmailService _emailService = new EmailService();
public void PlaceOrder()
{
// Place order logic
_emailService.Send("Order placed successfully.");
}
}
OrderService is directly coupled to EmailService.
Suppose the application later needs to use an SMS service instead, or a fake email service during testing. The OrderService implementation must change.
DI separates these responsibilities.
public interface IMessageService
{
void Send(string message);
}
public class EmailService : IMessageService
{
public void Send(string message)
{
Console.WriteLine($"Email: {message}");
}
}
public class OrderService
{
private readonly IMessageService _messageService;
public OrderService(IMessageService messageService)
{
_messageService = messageService;
}
public void PlaceOrder()
{
// Place order logic
_messageService.Send("Order placed successfully.");
}
}
The OrderService no longer needs to know which concrete messaging implementation is being used.
This provides several advantages:
Loose coupling
Easier unit testing
Easier replacement of implementations
Better separation of concerns
Centralized dependency configuration
Improved maintainability
Dependency Injection and Inversion of Control
Dependency Injection and Inversion of Control are related concepts, but they are not the same thing.
What Is Inversion of Control?
Inversion of Control (IoC) is a broader architectural principle in which control over certain operations is transferred from application code to an external component or framework.
In traditional code, a class might create and manage everything it needs:
Application
|
+-- Creates Service
|
+-- Creates Repository
|
+-- Creates Database Connection
With IoC, the framework or another external component manages this process.
Application
|
+-- Requests Service
|
+-- DI Container
|
+-- Creates Service
+-- Provides Repository
+-- Provides other dependencies
The framework becomes responsible for resolving the dependency graph.
How Does DI Achieve IoC?
Dependency Injection is one technique for implementing Inversion of Control.
For example, instead of writing:
var service = new OrderService(new EmailService());
the application can register the dependencies:
builder.Services.AddTransient<IMessageService, EmailService>();
builder.Services.AddTransient<OrderService>();
The DI container can then create OrderService and provide its required IMessageService.
Therefore:
IoC is the broader principle.
DI is a technique used to achieve IoC.
DI container is the mechanism that manages dependency registration and resolution.
Built-in Dependency Injection in .NET
Modern .NET applications include a built-in DI container.
In an ASP.NET Core application, services are commonly registered through builder.Services in Program.cs.
The two important abstractions are:
IServiceCollection— used to register services.IServiceProvider— used to resolve services at runtime.
IServiceCollection
IServiceCollection contains the service registrations used by the application.
For example:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IMyService, MyService>();
var app = builder.Build();
app.Run();
The following registration tells the DI container:
When IMyService is requested,
create and provide MyService.
Service registrations also specify the lifetime of the service.
IServiceProvider
IServiceProvider is responsible for resolving registered services.
In normal ASP.NET Core application code, developers generally do not need to call GetService or GetRequiredService manually. Instead, the framework resolves constructor or endpoint parameters automatically.
For example:
public class HomeController : Controller
{
private readonly IMyService _myService;
public HomeController(IMyService myService)
{
_myService = myService;
}
}
ASP.NET Core sees the IMyService dependency and asks the DI container for the registered implementation.
The Composition Root
Service registration is commonly centralized in Program.cs, which acts as the application's composition root.
For example:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IMyService, MyService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IApplicationCache, ApplicationCache>();
var app = builder.Build();
app.Run();
Keeping dependency configuration in one place makes the application's dependency graph easier to understand and change.
For larger applications, registrations can also be grouped into extension methods.
Service Lifetimes in .NET
.NET provides three primary service lifetimes:
Transient
Scoped
Singleton
Choosing the correct lifetime is important because it determines how long a service instance remains available.
Transient Lifetime
A transient service creates a new instance each time it is requested from the DI container.
Registration:
builder.Services.AddTransient<IMyService, MyService>();
Transient services are commonly appropriate for lightweight, stateless services.
For example:
public interface IMessageFormatter
{
string Format(string message);
}
public class MessageFormatter : IMessageFormatter
{
public string Format(string message)
{
return $"Message: {message}";
}
}
Registration:
builder.Services.AddTransient<IMessageFormatter, MessageFormatter>();
A new MessageFormatter instance can be created whenever the service is requested.
When to Use Transient
Transient is generally suitable for:
Stateless business services
Formatting utilities
Lightweight calculations
Data transformation services
The main consideration is object creation overhead if a transient service is requested very frequently.
Scoped Lifetime
A scoped service is created once per DI scope.
In an ASP.NET Core web application, a scope normally corresponds to an HTTP request.
Registration:
builder.Services.AddScoped<IOrderService, OrderService>();
If several components request IOrderService during the same request, they receive the same scoped instance.
A common example is Entity Framework Core's DbContext, which is normally registered with a scoped lifetime in ASP.NET Core applications.
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
When to Use Scoped
Scoped services are commonly used for:
Database contexts
Unit of Work implementations
Request-specific business services
Services that should share state within one request
A scoped service should not be treated as globally shared application state.
Singleton Lifetime
A singleton service uses one instance for the lifetime of the application's service provider.
Registration:
builder.Services.AddSingleton<IApplicationCache, ApplicationCache>();
All consumers that resolve the service from the same root provider receive the same instance.
Singleton services are appropriate when the service is:
Stateless
Thread-safe
Expensive to create
Designed to maintain application-wide state
Singleton and Thread Safety
Because a singleton can be accessed concurrently by multiple requests, mutable state inside a singleton must be designed for concurrent access.
For example, this is potentially unsafe:
public class CounterService
{
public int Count { get; set; }
}
If multiple requests modify Count, the implementation must account for concurrency.
Avoid Captive Dependencies
A longer-lived service should not directly capture a shorter-lived dependency.
For example:
Singleton
|
+-- Scoped Service
This can cause the scoped dependency to be retained beyond the lifetime for which it was designed.
A particularly important example is attempting to inject a scoped DbContext directly into a singleton.
If a singleton genuinely needs to work with a scoped service, the design should create an appropriate scope when performing that operation rather than capturing the scoped dependency in the singleton constructor.
Service Lifetime Comparison
Lifetime | Instance Behavior | Common Uses | Main Consideration |
|---|---|---|---|
Transient | New instance when requested | Lightweight, stateless services | More object creation |
Scoped | One instance per scope |
| Scope boundaries matter |
Singleton | One instance for the service-provider lifetime | Shared, thread-safe services | Concurrency and state management |
Join the conversation! Your thoughts help the community grow.