TL;DR: Dependency Injection prevents tight coupling. Use Transient for lightweight stateless services, Scoped for per-request database contexts, and Singleton for heavy, thread-safe caches. In .NET 8/9, use Keyed Services when you need multiple implementations of the same interface.
Dependency Injection (DI) is the beating heart of modern .NET. If you've written an ASP.NET Core application, you've used DI—but are you using it correctly?
Misunderstanding service lifetimes is the fastest way to create memory leaks, DbContext concurrency crashes, and untestable spaghetti code. In this guide, we’ll explore the real-world application of DI in .NET 9.
Why Do We Need DI?
Without DI, your classes instantiate their own dependencies, cementing a hard-coded link to a specific implementation.
❌ The Anti-Pattern (Tight Coupling)
public class OrderService
{
private readonly EmailSender _emailSender;
public OrderService()
{
// BAD: OrderService is permanently glued to EmailSender.
// Good luck writing a unit test for this without sending a real email!
_emailSender = new EmailSender();
}
}
✅ The Solution (Constructor Injection)
public class OrderService
{
private readonly IEmailSender _emailSender;
// GOOD: OrderService doesn't care HOW the email is sent.
// We can easily inject a MockEmailSender during unit testing.
public OrderService(IEmailSender emailSender)
{
_emailSender = emailSender;
}
}
The Three Service Lifetimes
When you register a service in Program.cs, you must declare how long the DI container should keep it alive. Getting this wrong is disastrous.
graph TD
A[HTTP Request 1] --> B(Scoped Service: Instance A)
A --> C(Transient Service: Instance X)
A --> D(Transient Service: Instance Y)
A --> E{Singleton Service: Instance 1}
F[HTTP Request 2] --> G(Scoped Service: Instance B)
F --> H(Transient Service: Instance Z)
F --> E
1. Transient (AddTransient)
A new instance is created every single time it is requested.
- Best For: Lightweight, stateless services (e.g., utility formatters, simple calculators).
- Danger: Do not use for objects that are expensive to create, as it will thrash the garbage collector.
2. Scoped (AddScoped)
A single instance is created per HTTP request. Every class that asks for this service during the same web request gets the exact same instance.
- Best For:
DbContext(Entity Framework), User Session state, HTTP-context specific data. - Danger: Never inject a Scoped service into a Singleton service (this causes the infamous "Capturing" memory leak).
3. Singleton (AddSingleton)
One single instance is created for the entire lifetime of the application. Every request shares this instance.
- Best For: In-memory caches, configuration loaders,
HttpClientFactory. - Danger: Must be 100% thread-safe. If you use standard generic lists or standard dictionaries inside a Singleton, heavy traffic will corrupt the data.
.NET 8/9 Feature: Keyed Services
Before .NET 8, injecting multiple implementations of the same interface was clunky and required custom factory patterns. Now, .NET natively supports Keyed Services.
Imagine you have two payment processors: Stripe and PayPal.
// 1. Register with a Key
builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
// 2. Inject via the Key
public class CheckoutService
{
private readonly IPaymentProcessor _paymentProcessor;
public CheckoutService([FromKeyedServices("stripe")] IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}
}
Summary
The built-in Microsoft DI container is incredibly powerful. By mastering Constructor Injection and strictly adhering to lifetime rules, you'll naturally write highly cohesive, loosely coupled, and thoroughly testable .NET applications.