Dependency Injection in .NET: The Advanced Features You're Ignoring
If you write modern .NET code, you use the built-in Microsoft.Extensions.DependencyInjection container. It is absolutely unavoidable. From ASP.NET Core web applications to headless worker services, Microsoft has baked Dependency Injection (DI) directly into the bedrock of the framework.
Usually, the standard developer interaction with the DI container goes exactly like this: you create an interface, you create a concrete implementation, and you blindly dump it into Program.cs with builder.Services.AddScoped<IMyService, MyService>();. Job done. You move on to the next task.
But over the last few years, Microsoft has quietly added some incredibly powerful features to this native container. Features that historically required you to pull in massive third-party libraries like Autofac or Ninject are now built right in.
If you are still just blindly registering simple singletons, you are missing out on significant architectural improvements. Here is a deep dive into the advanced DI features you should be utilizing to clean up your codebase.
Table of Contents
- 1. Service Lifetimes: A Quick Refresher
- 2. Keyed Services (The Factory Killer)
- 3. The Captive Dependency Anti-Pattern
- 4. The BackgroundService Trap
- 5. TryAdd vs Add for Library Authors
- 6. Open Generics Registration
- 7. IServiceCollection vs IServiceProvider
- Final Thoughts
1. Service Lifetimes: A Quick Refresher
Before diving into advanced features, we must establish a baseline understanding of how Dependency Injection in .NET handles object lifecycles. There are exactly three lifetimes:
- Transient (
AddTransient): A new instance of the service is created every single time it is requested. If a controller requestsIMyServiceand the repository it depends on also requestsIMyService, two completely separate objects are instantiated. Use this for lightweight, stateless services. - Scoped (
AddScoped): A new instance is created once per client request (HTTP request). Every component within that same HTTP request shares the exact same instance. This is strictly required for Entity FrameworkDbContextobjects. - Singleton (
AddSingleton): A single instance is created the first time it is requested, and that exact same memory reference is shared across every single request in the entire application until the server shuts down.
Understanding these lifetimes is critical, because mixing them incorrectly will crash your application (as we will explore in Section 3).
2. Keyed Services (The Factory Killer)
For years, the native .NET DI container struggled with a very specific problem: registering multiple implementations of the exact same interface.
Imagine you had an IPaymentGateway interface, and two concrete implementations: StripeGateway and PayPalGateway. If you registered both in Program.cs, and a controller asked for an IPaymentGateway, the DI container would simply hand you whichever one you registered last.
To get around this, we historically had to build ugly Factory patterns: Func<string, IPaymentGateway>. It required writing tedious switch statements that violated the Open-Closed Principle.
As of .NET 8, Keyed Services are natively supported. You can now register multiple implementations of the exact same interface, tagged with a unique string or enum key:
// Register with a specific string key
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, PayPalGateway>("paypal");
When you need to inject them, you don't use a factory. You simply use the [FromKeyedServices] attribute in your constructor:
// Utilizing C# 12 Primary Constructors for clean injection
public class CheckoutService(
[FromKeyedServices("stripe")] IPaymentGateway stripe,
[FromKeyedServices("paypal")] IPaymentGateway paypal)
{
public void Pay(string method)
{
if (method == "stripe") stripe.Process();
else if (method == "paypal") paypal.Process();
}
}
This single feature allows you to rip out hundreds of lines of boilerplate factory classes, making your code significantly cleaner and easier to unit test. (For more ways to reduce boilerplate, check out my guide on C# 12 Primary Constructors).
3. The Captive Dependency Anti-Pattern
The most dangerous bug you can introduce using Dependency Injection is known as a Captive Dependency.
A captive dependency occurs when a service with a longer lifetime (like a Singleton) injects a service with a shorter lifetime (like a Scoped service).
Because the Singleton is only instantiated once during application startup, its constructor is only called once. If you inject a Scoped service (like an EF Core DbContext) into that constructor, the Singleton grabs a hold of that specific DbContext instance and never lets it go.
You have just accidentally turned your Scoped database context into a Singleton.
As the application processes thousands of HTTP requests, that single DbContext will attempt to track every single entity ever queried. Eventually, it will suffer a concurrency crash or cause a massive OutOfMemory (OOM) exception.
I see this constantly when developers write custom ASP.NET Core Middleware. Because convention-based middleware is instantiated as a Singleton, injecting a Scoped service into its constructor is a fatal error.
The Rule: A Transient service can inject anything. A Scoped service can inject Scoped or Singleton. A Singleton can only inject other Singletons.
4. The BackgroundService Trap
This captive dependency issue rears its ugly head most frequently when dealing with background tasks.
You create a class inheriting from BackgroundService to process queue messages. You register it using builder.Services.AddHostedService<MyWorker>();. Hosted Services are Singletons.
If you try to inject your Scoped ApplicationDbContext into your background worker's constructor, ASP.NET Core will actually throw a massive exception at startup and crash the application, attempting to protect you from yourself.
The Fix: If a Singleton (like a Hosted Service) needs to use a Scoped service, you must inject the IServiceProvider and manually create a scope when the task runs.
public class MyBackgroundWorker(IServiceProvider services) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// ✅ Correct way to use Scoped services in a Singleton worker
using var scope = services.CreateScope();
// Resolve the scoped service from the manual scope
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await db.Users.AddAsync(new User { Name = "Test" });
await db.SaveChangesAsync(stoppingToken);
}
}
(Note: If your background worker is doing heavy asynchronous I/O, make sure you aren't accidentally blocking the thread. Read my Async/Await Best Practices guide for more details).
5. TryAdd vs Add for Library Authors
If you are writing an internal class library for your company, or publishing an open-source NuGet package, you should almost never use .AddScoped(). You should be using .TryAddScoped() from the Microsoft.Extensions.DependencyInjection.Extensions namespace.
Why? Because when a developer consumes your library, they might want to override your default implementation with their own custom logic.
// In your library's registration extension method:
services.TryAddScoped<IEmailSender, DefaultEmailSender>();
// In the consuming application's Program.cs:
builder.Services.AddScoped<IEmailSender, CustomSendGridSender>();
builder.Services.AddMyLibrary();
Because you used TryAdd, the DI container inspects its current registry, sees that the application has already registered an IEmailSender, and politely skips registering your DefaultEmailSender.
If you had blindly used .AddScoped(), you would have overwritten the application's custom implementation with your default one, leading to incredibly frustrating debugging sessions for the developers using your library.
6. Open Generics Registration
One of the most powerful but rarely used features of the native container is its ability to register Open Generics.
If you have a generic repository interface, IRepository<T>, you don't need to manually register IRepository<User>, IRepository<Order>, and IRepository<Product> one by one. You can register the open generic type, and the container will figure out the rest dynamically at runtime:
// The typeof() syntax without type arguments is an "Open Generic"
builder.Services.AddScoped(typeof(IRepository<>), typeof(GenericRepository<>));
Now, any controller can request an IRepository<Invoice> in its constructor, and the DI container will magically construct a GenericRepository<Invoice> on the fly.
(Note: If you are aggressively optimizing your application with .NET 8 Native AOT, be careful with open generics. The AOT compiler's static analysis occasionally struggles to map open generics during the trimming phase, though support improves with every release).
7. IServiceCollection vs IServiceProvider
A final point of confusion for many developers is the difference between these two interfaces.
IServiceCollectionis a simple list. It is a collection ofServiceDescriptorobjects. You interact with this during the Configuration Phase (inProgram.cs) to define how objects should be created.IServiceProvideris the actual engine. Once you callbuilder.Build(), theIServiceCollectionis locked, compiled, and transformed into anIServiceProvider. You interact with this during the Execution Phase to actually request and instantiate objects.
You cannot add new services to the provider once it has been built. The registry is immutable at runtime, which guarantees thread safety and high performance.
Final Thoughts
The native .NET Dependency Injection container is no longer a "lightweight compromise" that you tolerate until you can install a third-party library. It is a fully-featured, high-performance beast that powers some of the largest microservices on the planet.
Use Keyed Services to clean up your factories, respect the difference between Scoped and Singleton lifetimes, manually create scopes when working in background services, and play nice with TryAdd if you are writing shared code.
Tired of writing boilerplate C# DTOs? If your backend services are constantly consuming complex JSON payloads from external APIs, stop typing out the models by hand. Use my completely free JSON to C# Converter to instantly generate pristine C# classes from any JSON string. No strings attached, 100% privacy-first.