Dependency injection is the secret sauce that makes your .NET applications flexible, but it’s not about injecting dependencies so much as it’s about injecting behavior.
Let’s see it in action. Imagine you have a service that needs to log messages, and another that needs to send emails.
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"[LOG] {message}");
}
}
public interface IEmailService
{
void SendEmail(string to, string subject, string body);
}
public class SmtpEmailService : IEmailService
{
private readonly ILogger _logger;
public SmtpEmailService(ILogger logger)
{
_logger = logger;
}
public void SendEmail(string to, string subject, string body)
{
_logger.Log($"Attempting to send email to {to} with subject '{subject}'");
// Actual SMTP sending logic would go here
Console.WriteLine($"Email sent to {to}");
}
}
public class MyService
{
private readonly IEmailService _emailService;
public MyService(IEmailService emailService)
{
_emailService = emailService;
}
public void DoWork()
{
_emailService.SendEmail("user@example.com", "Work Done", "Your task is complete.");
}
}
Now, how do we wire this up so MyService gets an IEmailService which, in turn, gets an ILogger? This is where the dependency injection container (like Microsoft.Extensions.DependencyInjection) comes in.
In your Program.cs (or Startup.cs in older ASP.NET Core versions), you configure the container:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
// Registering services with the container
services.AddTransient<ILogger, ConsoleLogger>(); // Every time you ask for ILogger, a new ConsoleLogger is created.
services.AddScoped<IEmailService, SmtpEmailService>(); // A new SmtpEmailService is created for each scope (e.g., web request).
services.AddSingleton<MyService>(); // Only one instance of MyService is ever created.
});
var app = builder.Build();
// Now, when you resolve MyService from the container, it automatically gets an IEmailService,
// and that IEmailService automatically gets an ILogger.
using (var scope = app.Services.CreateScope())
{
var myService = scope.ServiceProvider.GetRequiredService<MyService>();
myService.DoWork();
}
The container’s job is to understand these relationships and fulfill them. When you ask for MyService, the container sees it needs an IEmailService. It looks up how IEmailService is registered, finds SmtpEmailService, and sees that SmtpEmailService needs an ILogger. It then looks up ILogger, finds ConsoleLogger, creates an instance of ConsoleLogger, and passes that to the SmtpEmailService constructor. Finally, it creates an instance of SmtpEmailService and passes that to the MyService constructor.
The core problem DI solves is managing object creation and their dependencies, moving it from the concrete classes themselves to an external configuration. This allows you to swap out implementations easily. For instance, you could later register services.AddTransient<IEmailService, MockEmailService>(); for testing, and MyService would still work without any changes.
The container maintains a registry of service types and their corresponding concrete implementations, along with their lifetime (how long an instance lives). AddTransient means a new instance is created every time it’s requested. AddScoped means one instance is created per scope (e.g., per web request in ASP.NET Core, or per CreateScope() call). AddSingleton means only one instance is ever created for the entire application’s lifetime.
When you request a service, the container first checks if an instance already exists for the current scope and lifetime. If not, it creates a new instance, injecting any required dependencies it needs to resolve first. This recursive resolution process builds up the entire object graph required for your initial request.
Here’s a crucial detail: the container doesn’t magically know how to create every single type. It can only create types that it has been configured to manage, or types that have public constructors with only parameters that the container can resolve. If SmtpEmailService had a constructor like SmtpEmailService(ILogger logger, OtherDependency other) and OtherDependency wasn’t registered, the container would throw an exception when trying to create SmtpEmailService.
The real magic of DI isn’t just about injecting objects; it’s about injecting interfaces or abstract classes. This decouples your code from specific implementations, enabling testability and maintainability. The container acts as a central orchestrator, ensuring that the right concrete types are wired up according to your configuration, fulfilling the contract defined by the interfaces.
The next hurdle you’ll often face is understanding how to manage complex object graphs with circular dependencies or how to configure advanced resolution behaviors like factory delegates.