Dependency injection gets sold as an abstract OOP principle, but the day-to-day reason we use it is much more mundane: it is the difference between a class you can unit test in isolation and a class you can only exercise by standing up its entire dependency graph, database included.
The Problem: Hardcoded Dependencies
A class that news up a concrete SqlEmailService inside its constructor or a method body has hardwired itself to that implementation. You cannot substitute a fake for a test, you cannot swap providers without editing the class itself, and every class that news up its own dependencies makes the real object graph invisible from the outside.
public class OrderService
{
private readonly SqlEmailService _emailService = new SqlEmailService();
public void PlaceOrder(Order order)
{
// ... persist the order ...
_emailService.SendConfirmation(order.CustomerEmail, order.Id);
}
}
Constructor Injection
The fix is to depend on an abstraction and let something else hand the class its dependency, and the constructor is the right place for anything the class cannot function without — it makes required dependencies visible in the type signature and impossible to forget to set.
public interface IEmailService
{
void SendConfirmation(string toAddress, int orderId);
}
public class OrderService
{
private readonly IEmailService _emailService;
public OrderService(IEmailService emailService)
{
_emailService = emailService;
}
public void PlaceOrder(Order order)
{
// ... persist the order ...
_emailService.SendConfirmation(order.CustomerEmail, order.Id);
}
}
// Program.cs
builder.Services.AddScoped<IEmailService, SqlEmailService>();
builder.Services.AddScoped<OrderService>();
Transient, Scoped, and Singleton
ASP.NET Core's built-in container gives you three lifetimes, and picking the wrong one is the most common DI bug we see. Transient creates a new instance every time it is requested — cheap, stateless services are the natural fit. Scoped creates one instance per HTTP request (or per unit of work outside a web request) — this is where most application and repository services belong, especially anything wrapping a DbContext. Singleton creates exactly one instance for the lifetime of the app — reserve it for genuinely stateless or thread-safe shared state, like configuration or an in-memory cache.
The bug worth knowing by name is the captive dependency: injecting a Scoped service into a Singleton silently pins that Scoped instance for the app's entire lifetime, defeating its purpose and often producing confusing DbContext-related exceptions since it is now shared across requests it was never designed to survive.
Why This Makes Testing Easier
Once OrderService depends on IEmailService rather than a concrete SqlEmailService, a unit test can hand it a mock (via Moq, NSubstitute, or a hand-written fake) that records what was called instead of actually sending an email, and assert on that — no test database, no SMTP server, no network call in the way of the thing you are actually testing.
- Transient — new instance every resolution; stateless helpers and validators
- Scoped — one instance per request; services and repositories wrapping a DbContext
- Singleton — one instance for the app's lifetime; configuration, caches, stateless clients
A wrong lifetime usually does not fail at compile time or even at registration time — it fails (or silently misbehaves) at resolution time. After any change to your DI registrations, run the app, or better, write a quick test that resolves every registered service, rather than assuming it compiles means it works.
Wrapping Up
DI is not about the container — it is about writing classes that declare what they need instead of reaching out and grabbing it. The container is just the thing that saves you from wiring that graph together by hand at every call site.