C# Dependency Injection Explained: Build Loosely Coupled Applications
Dependency injection is a common design technique in modern C# and .NET applications. It allows classes to receive the services they depend on instead of creating those services directly.
What is Dependency Injection?
Dependency injection, often called DI, is a technique where an object receives its required dependencies from an external source. This reduces tight coupling and makes components easier to replace and test.
interface IMessageSender
{
void Send(string message);
}
class EmailSender : IMessageSender
{
public void Send(string message)
{
Console.WriteLine($"Email: {message}");
}
}
class NotificationService
{
private readonly IMessageSender sender;
public NotificationService(IMessageSender sender)
{
this.sender = sender;
}
public void Notify(string message)
{
sender.Send(message);
}
}
Why Use Dependency Injection?
- Reduce tight coupling between classes
- Make implementations easier to replace
- Improve unit testing
- Centralize service configuration
- Support cleaner application architecture
- Make large applications easier to maintain
Without Dependency Injection
Without DI, a class may create its dependencies directly. This makes the class responsible for both its own behavior and dependency construction.
class NotificationService
{
private readonly EmailSender sender = new EmailSender();
public void Notify(string message)
{
sender.Send(message);
}
}
Constructor Injection
Constructor injection is the most common form of dependency injection in C#. Dependencies are provided through the class constructor.
class NotificationService
{
private readonly IMessageSender sender;
public NotificationService(IMessageSender sender)
{
this.sender = sender;
}
public void Notify(string message)
{
sender.Send(message);
}
}
Interface-Based Dependency Injection
Using interfaces allows an application to depend on an abstraction rather than a specific implementation.
interface IPaymentService
{
void Process(decimal amount);
}
class CardPaymentService : IPaymentService
{
public void Process(decimal amount)
{
Console.WriteLine($"Card payment: {amount}");
}
}
class OrderService
{
private readonly IPaymentService paymentService;
public OrderService(IPaymentService paymentService)
{
this.paymentService = paymentService;
}
}
Dependency Injection in .NET
Modern .NET applications commonly use the built-in dependency injection container. Services can be registered with the application's service collection and automatically supplied to constructors.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IMessageSender, EmailSender>();
builder.Services.AddScoped<NotificationService>();
var app = builder.Build();
Service Registration
The service collection provides registration methods that describe how a dependency should be created and managed.
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<ICacheService, CacheService>();
Transient Lifetime
Transient services are created each time the dependency is requested. They are commonly suitable for lightweight, stateless services.
builder.Services.AddTransient<IMessageFormatter, MessageFormatter>();
Scoped Lifetime
Scoped services are created once per scope. In ASP.NET Core applications, a request commonly represents a scope.
builder.Services.AddScoped<IOrderService, OrderService>();
Singleton Lifetime
Singleton services use one instance for the lifetime of the application or service provider.
builder.Services.AddSingleton<IApplicationCache, ApplicationCache>();
Service Lifetime Comparison
| Lifetime | Instance Creation | Typical Use |
|---|---|---|
| Transient | New instance each time requested | Lightweight stateless services |
| Scoped | One instance per scope | Request-based application services |
| Singleton | One instance for the service provider lifetime | Shared application-wide services |
Multiple Dependencies
A class can receive multiple dependencies through its constructor. Each dependency can represent a separate responsibility.
class OrderService
{
private readonly IPaymentService payment;
private readonly INotificationService notification;
private readonly ILogger<OrderService> logger;
public OrderService(
IPaymentService payment,
INotificationService notification,
ILogger<OrderService> logger)
{
this.payment = payment;
this.notification = notification;
this.logger = logger;
}
}
Dependency Injection and Testing
DI makes unit testing easier because a real dependency can be replaced with a fake or mock implementation.
class FakeMessageSender : IMessageSender
{
public string? LastMessage { get; private set; }
public void Send(string message)
{
LastMessage = message;
}
}
var fakeSender = new FakeMessageSender();
var service = new NotificationService(fakeSender);
service.Notify("Test message");
Console.WriteLine(fakeSender.LastMessage);
DI and Configuration
Dependency injection can also be used to provide configuration, logging, database services, HTTP clients, and other infrastructure components.
builder.Services.AddHttpClient<WeatherService>();
builder.Services.AddLogging();
builder.Services.AddOptions();
Dependency Injection vs Direct Creation
| Approach | Dependency Creation | Flexibility |
|---|---|---|
| Direct creation | Class creates dependency | Lower |
| Constructor injection | External container or caller provides dependency | High |
| Interface-based DI | Implementation can be replaced | Very high |
Common DI Mistakes
- Registering services with the wrong lifetime
- Creating dependencies manually inside DI-managed classes
- Injecting too many unrelated dependencies
- Using singleton services with unsafe shared mutable state
- Depending on concrete implementations unnecessarily
- Treating DI as a replacement for good architecture
Dependency Injection Best Practices
- Prefer constructor injection for required dependencies
- Depend on abstractions when replacement is useful
- Choose service lifetimes carefully
- Keep services focused on clear responsibilities
- Avoid excessively large constructors
- Keep dependency registration centralized and understandable
Real-World Applications
- ASP.NET Core Web APIs
- Database repositories
- Payment services
- Email and notification systems
- Logging infrastructure
- External API clients
- Caching services
Practice Exercises
- Create an ILogger interface and implementation
- Inject a service into another class
- Register services using AddTransient
- Create a scoped application service
- Build a singleton cache service
- Replace a real dependency with a fake implementation
- Create a small ASP.NET Core application using DI
Conclusion
Dependency injection helps C# applications separate responsibilities and reduce coupling between components. By using constructor injection, interfaces, and appropriate service lifetimes, developers can create applications that are easier to test, maintain, and extend.