Dependency Injection in .NET: From Lifecycles to Keyed Services
1. Introduction
Dependency Injection (DI) is the backbone of modern .NET applications. While registering services with AddScoped or AddSingleton might seem simple, misunderstanding how DI works can lead to memory leaks, thread-safety issues, and hard-to-track bugs. This post explores the nuances of DI lifecycles and advanced features like Keyed Services.
2. Service Lifecycles Explained
In .NET, the built-in DI container manages the lifetime of a service based on how it is registered:
- Transient (
AddTransient): A new instance is created every time the service is requested. Use this for lightweight, stateless services. - Scoped (
AddScoped): A new instance is created once per client request (within a single scope). Ideal for database contexts (DbContext). - Singleton (
AddSingleton): A single instance is created the first time it is requested and remains for the application’s lifetime. Use this for configuration or stateful shared caches.
3. The “Captive Dependency” Problem
This is a common pitfall that senior developers must recognize. A Captive Dependency occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime.
Example:
If you inject a Scoped service (like a database repository) into a Singleton service (like a background worker):
- The
Scopedservice will live as long as theSingleton(the entire app life). - This can lead to database connection leaks or stale data because the
Scopedservice is never disposed of.
Solution: Use IServiceScopeFactory to create a manual scope inside the Singleton service when you need to access Scoped dependencies.
4. Keyed Services (.NET 8+)
Before .NET 8, if you had multiple implementations of the same interface, resolving a specific one was cumbersome. Now, we have Keyed Services.
Registration:
builder.Services.AddKeyedSingleton<INotificationService, EmailService>("email");
builder.Services.AddKeyedSingleton<INotificationService, SmsService>("sms");
Usage:
You can use the [FromKeyedServices] attribute in your constructor:
public class OrderProcessor([FromKeyedServices("email")] INotificationService mailService)
{
// Uses EmailService automatically
}
5. Best Practices
- Register by Interface: Always prefer
AddScoped<IMyService, MyService>()over concrete types to ensure testability. - Avoid Service Locator Pattern: Don’t inject
IServiceProvidereverywhere. Let the constructor do the work. - Keep Constructors Simple: A constructor should only assign dependencies to fields. Avoid heavy logic or database calls in the constructor.
- Use
ValidateOnBuild: In yourProgram.cs, you can enable DI validation to catch lifecycle errors (like Captive Dependencies) during startup:builder.Host.UseDefaultServiceProvider(options => { options.ValidateScopes = true; options.ValidateOnBuild = true; });
6. Conclusion
Mastering Dependency Injection is about understanding control. By choosing the right lifecycles and avoiding captive dependencies, you build applications that are stable and memory-efficient. Keyed Services provide the final piece of the puzzle for handling complex architectural requirements cleanly.
Leave a comment