Dependency injection in .NET is the backbone of every modern application model, from ASP.NET Core and worker services to .NET MAUI and Azure Functions. This guide is for developers who already register services daily but want to understand the container deeply: how lifetimes really behave, why captive dependencies cause production bugs, how keyed services, open generics and decorators work, and when a third-party container is worth it. Every example targets the built-in Microsoft.Extensions.DependencyInjection container on .NET 8 through .NET 10.
What Is Dependency Injection in .NET?#
Dependency injection (DI) is a technique for achieving inversion of control: a class declares the collaborators it needs, usually as constructor parameters, and something outside the class supplies them. The class no longer decides which implementation it gets or how that implementation is built, so it becomes easier to test, reuse and change.
.NET ships a DI container as part of the Microsoft.Extensions.* libraries, and every host builder wires it up for you. It is deliberately minimal. It supports constructor injection, three lifetimes, factories, open generics, enumerable resolution and, since .NET 8, keyed services. It does not support property injection, child containers or convention-based scanning out of the box, which keeps it fast and predictable. The same abstractions are used by the whole framework, so logging, configuration, HttpClient factories, EF Core and hosted services all arrive through the same mechanism your own services use.
How the .NET DI Container Works#
The container has two phases, and keeping them separate in your head explains most of its behavior.
- Registration. You add
ServiceDescriptorobjects to anIServiceCollection. Each descriptor records a service type, a lifetime and one of three ways to create the instance: an implementation type, a factory delegate or an existing instance. Methods such asAddScoped<TService, TImplementation>()are thin helpers that create descriptors. - Resolution. The host calls
BuildServiceProvider(), which produces an immutableIServiceProvider. When you ask it for a service, it finds the last descriptor registered for that type, builds the dependency graph and caches instances according to their lifetimes.
When an implementation has several public constructors, the container picks the one with the most parameters it can satisfy; if two candidates are equally good, it throws rather than guessing. Constructor parameters that the container cannot resolve must have default values.
Scopes are the third concept. An IServiceScope owns an IServiceProvider that caches scoped instances and tracks disposable objects it created. ASP.NET Core creates one scope per HTTP request, while in console apps and workers you create scopes yourself through IServiceScopeFactory. The root provider is also a scope, but one that lives until the application shuts down, which is why resolving short-lived services from it causes trouble.
Asking for IEnumerable<T> returns every registration for T in registration order, while asking for T returns only the last one. That rule is what makes "override by registering again" work, and it is also the reason why careless duplicate registrations can silently change behavior.
Getting Started with Dependency Injection#
The generic host gives you a configured container, logging and configuration in a few lines. This console application registers three services with different lifetimes and resolves the entry point inside an explicit scope:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
builder.Services.AddTransient<OrderService>();
using var host = builder.Build();
await using (var scope = host.Services.CreateAsyncScope())
{
var orders = scope.ServiceProvider.GetRequiredService<OrderService>();
await orders.PlaceAsync(new Order(Guid.NewGuid(), 42.50m));
}
public sealed record Order(Guid Id, decimal Total);
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct = default);
}
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = [];
public Task SaveAsync(Order order, CancellationToken ct = default)
{
_orders.Add(order);
return Task.CompletedTask;
}
}
public sealed class OrderService(
IOrderRepository repository, TimeProvider clock, ILogger<OrderService> logger)
{
public async Task PlaceAsync(Order order, CancellationToken ct = default)
{
await repository.SaveAsync(order, ct);
logger.LogInformation("Order {OrderId} placed at {Time}", order.Id, clock.GetUtcNow());
}
}Primary constructors keep the injection noise low, and GetRequiredService fails fast with a clear exception when a registration is missing, whereas GetService returns null. Prefer the required variant everywhere except in code that genuinely treats the service as optional. The host setup itself, including configuration and logging, is covered in the Generic Host guide.
Service Lifetimes: Transient, Scoped and Singleton#
Choosing a lifetime is a correctness decision, not a performance tweak. The lifetime defines who shares an instance and when it is disposed.
| Lifetime | Instance created | Disposed | Typical use | Watch out for |
|---|---|---|---|---|
| Transient | On every resolution | When the resolving scope ends | Lightweight, stateless services | Disposable transients resolved from the root leak |
| Scoped | Once per scope (per HTTP request in ASP.NET Core) | When the scope ends | DbContext, unit of work, per-request context | Must never be captured by a singleton |
| Singleton | Once per container | When the container is disposed | Caches, clients, expensive shared state | Must be thread-safe and must not hold scoped services |
A useful rule of thumb: a service may depend on services with the same or a longer lifetime, never on a shorter one. A scoped service can take a singleton, and a transient can take anything, but a singleton that depends on a scoped service holds that scoped instance forever. The container guarantees that resolving services is thread-safe; it does not make the resolved singletons thread-safe, so shared state inside them still needs synchronization.
Scope Validation and Captive Dependencies#
A captive dependency occurs when a longer-lived service holds on to a shorter-lived one. The classic case is a singleton that takes a DbContext: the context is created once, shared across concurrent requests and never disposed, and because DbContext is not thread-safe, parallel requests start failing with concurrency exceptions. Without validation, this bug usually shows up under load in production.
The container can detect it. ValidateScopes makes resolution fail when a scoped service is resolved from the root provider or consumed by a singleton, and ValidateOnBuild tries to construct every registration's dependency graph when the provider is built, so missing registrations fail at startup instead of on the first request. The default host builders enable both only in the Development environment. Turn them on wherever you run integration tests as well:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});When a singleton genuinely needs short-lived work, give it a way to create its own scope or instance instead of capturing one. For EF Core, the cleanest fix is IDbContextFactory<TContext>, which is registered as a singleton by AddDbContextFactory:
builder.Services.AddDbContextFactory<ShopDbContext>(
o => o.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddSingleton<PriceCache>();
public sealed class PriceCache(IDbContextFactory<ShopDbContext> contextFactory)
{
public async Task<decimal?> GetPriceAsync(string sku, CancellationToken ct)
{
// A fresh, short-lived context per call; disposed before returning.
await using var db = await contextFactory.CreateDbContextAsync(ct);
return await db.Products
.Where(p => p.Sku == sku)
.Select(p => (decimal?)p.Price)
.FirstOrDefaultAsync(ct);
}
}For other scoped services, inject IServiceScopeFactory and create an async scope per unit of work. This is exactly how hosted services consume scoped dependencies, as shown in the background services guide. Note that ValidateOnBuild cannot see inside factory delegates, so graphs built by factories are only validated when they are first resolved.
Factories and ActivatorUtilities#
Most registrations should use implementation types, because the container can analyze and validate them. Factories are the right tool when construction depends on runtime data, such as configuration that selects between implementations, or when a third-party type has a constructor the container cannot satisfy.
builder.Services.AddSingleton<IBlobStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
return options.UseLocalDisk
? new DiskBlobStore(options.RootPath)
// Supplies the container name; everything else comes from the container.
: ActivatorUtilities.CreateInstance<AzureBlobStore>(sp, options.ContainerName);
});ActivatorUtilities.CreateInstance builds a type that is not registered, mixing explicit arguments with services from the provider. Framework features such as middleware activation use it internally. If a type has several constructors, mark the one it should use with [ActivatorUtilitiesConstructor].
Keep factories fast, synchronous and free of side effects. The official guidelines explicitly warn against async factories that block with .Result, which can deadlock. If an object needs asynchronous initialization, register it normally and expose an InitializeAsync method, or perform the work in a hosted service at startup.
Keyed Services in .NET 8 and Later#
Before .NET 8, choosing between several implementations of the same interface meant writing a factory or a dictionary of delegates. Keyed services make this a first-class feature: you register implementations under a key (any object, typically a string or enum) and request them by key.
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedSingleton<IPaymentGateway, AdyenGateway>("adyen");
// Fallback for any key without an explicit registration
builder.Services.AddKeyedSingleton<IPaymentGateway, SandboxGateway>(KeyedService.AnyKey);
public sealed class RefundService([FromKeyedServices("stripe")] IPaymentGateway gateway)
{
public Task RefundAsync(string paymentId, CancellationToken ct) =>
gateway.RefundAsync(paymentId, ct);
}
// The implementation can receive its own key through [ServiceKey]
public sealed class AdyenGateway([ServiceKey] string key, ILogger<AdyenGateway> logger)
: IPaymentGateway
{
public Task RefundAsync(string paymentId, CancellationToken ct)
{
logger.LogInformation("Refund {PaymentId} via {Gateway}", paymentId, key);
return Task.CompletedTask;
}
}
// Runtime selection, for example from a route value
app.MapPost("/refunds/{provider}/{paymentId}",
(string provider, string paymentId, IServiceProvider sp, CancellationToken ct) =>
sp.GetRequiredKeyedService<IPaymentGateway>(provider).RefundAsync(paymentId, ct));ASP.NET Core understands [FromKeyedServices] on minimal API handler parameters and controller actions, and since .NET 9, IHttpClientFactory can register named clients as keyed services through AddAsKeyed(). .NET 10 refines the model in two ways. [FromKeyedServices] now has a parameterless form that inherits the key of the service being constructed, which is handy for per-tenant object graphs, and calling GetKeyedService (singular) with KeyedService.AnyKey now throws an InvalidOperationException; use GetKeyedServices (plural) with AnyKey when you need to enumerate keyed registrations.
// .NET 10+: TenantReport resolved with key "contoso" gets the "contoso" database
builder.Services.AddKeyedScoped<ITenantDatabase, TenantDatabase>("contoso");
builder.Services.AddKeyedScoped<ITenantDatabase, TenantDatabase>("fabrikam");
builder.Services.AddKeyedScoped<TenantReport>("contoso");
builder.Services.AddKeyedScoped<TenantReport>("fabrikam");
public sealed class TenantReport([FromKeyedServices] ITenantDatabase database)
{
public Task<int> CountOrdersAsync(CancellationToken ct) => database.CountOrdersAsync(ct);
}Use keys for genuine variants of one abstraction, such as providers, regions or tenants. If you find yourself adding keys to distinguish unrelated responsibilities, separate interfaces are clearer.
Open Generics and Multiple Implementations#
Open generic registrations map a generic service definition to a generic implementation once, and the container closes the types on demand. This is how ILogger<T> and IOptions<T> work without per-type registrations. Combined with enumerable resolution, it enables clean pipelines such as validation:
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// TryAddEnumerable skips a registration if the same implementation already exists,
// which makes library registration methods safe to call twice.
builder.Services.TryAddEnumerable(
ServiceDescriptor.Transient<IValidator<Order>, OrderTotalValidator>());
builder.Services.TryAddEnumerable(
ServiceDescriptor.Transient<IValidator<Order>, OrderLinesValidator>());
public sealed class ValidatingSaver<T>(IRepository<T> repository,
IEnumerable<IValidator<T>> validators) where T : class
{
public async Task SaveAsync(T entity, CancellationToken ct)
{
var errors = validators.SelectMany(v => v.Validate(entity)).ToList();
if (errors.Count > 0)
{
throw new ValidationException(string.Join("; ", errors));
}
await repository.AddAsync(entity, ct);
}
}The TryAdd* family is essential for library authors. TryAddSingleton and friends register a service only if the type has no registration yet, so applications can override a library's default by registering first. TryAddEnumerable checks the implementation type as well, preventing duplicate entries in IEnumerable<T>. For deliberate overrides, Replace and RemoveAll from Microsoft.Extensions.DependencyInjection.Extensions are more explicit than relying on registration order.
Decorators: Manual and with Scrutor#
The decorator pattern wraps a service with another implementation of the same interface to add caching, retries, metrics or authorization without touching the original class. The built-in container has no Decorate method, but keyed services make a manual decorator straightforward:
builder.Services.AddMemoryCache();
builder.Services.AddKeyedScoped<IProductCatalog, SqlProductCatalog>("inner");
builder.Services.AddScoped<IProductCatalog, CachedProductCatalog>();
public sealed class CachedProductCatalog(
[FromKeyedServices("inner")] IProductCatalog inner,
IMemoryCache cache) : IProductCatalog
{
public Task<Product?> FindAsync(string sku, CancellationToken ct) =>
cache.GetOrCreateAsync($"product:{sku}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return inner.FindAsync(sku, ct);
});
}The open-source Scrutor library adds a Decorate extension and assembly scanning on top of IServiceCollection without replacing the container. It is a popular choice when an application has many decorators or wants convention-based registration:
builder.Services.AddScoped<IProductCatalog, SqlProductCatalog>();
// Here CachedProductCatalog takes a plain IProductCatalog inner parameter
builder.Services.Decorate<IProductCatalog, CachedProductCatalog>();
builder.Services.Scan(scan => scan
.FromAssemblyOf<OrderService>()
.AddClasses(classes => classes.AssignableTo(typeof(IValidator<>)))
.AsImplementedInterfaces()
.WithTransientLifetime());Scanning trades explicitness for convenience. It works well for large families of similar types such as validators or handlers, but keep core infrastructure registrations explicit so a reader can find them. The design patterns guide covers decorators and related patterns beyond DI.
Disposal: IDisposable and IAsyncDisposable#
The container disposes what it creates. Transient and scoped instances are disposed when their scope ends, and singletons when the root provider is disposed at shutdown. Instances you create yourself and register with AddSingleton(instance) are not disposed by the container, because it does not own them.
Two disposal rules cause real incidents. First, a transient IDisposable resolved from the root provider is tracked by the root until the application exits, so resolving one per operation from the root is a memory leak. Always resolve such services from a scope. Second, if a scope contains a service that implements only IAsyncDisposable, disposing the scope synchronously throws an InvalidOperationException. Dispose scopes asynchronously:
public sealed class NightlyExport(IServiceScopeFactory scopeFactory)
{
public async Task RunAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var exporter = scope.ServiceProvider.GetRequiredService<ReportExporter>();
await exporter.ExportAsync(ct);
} // DisposeAsync runs here, awaiting IAsyncDisposable services
}Do not call Dispose on services you received through injection. The container owns them, and disposing a shared instance breaks every other consumer.
Testing with Dependency Injection#
DI pays off most in tests. Unit tests construct classes directly with fakes, with no container involved at all. Integration tests keep the real container and replace only the boundaries, such as email, payment or clock services:
public sealed class CheckoutTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Checkout_sends_confirmation_email()
{
var emails = new FakeEmailSender();
var client = factory.WithWebHostBuilder(web => web.ConfigureTestServices(services =>
{
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender>(emails);
})).CreateClient();
var response = await client.PostAsJsonAsync("/checkout", new { Sku = "BOOK-1" });
response.EnsureSuccessStatusCode();
Assert.Single(emails.Sent);
}
}Running integration tests with ValidateScopes and ValidateOnBuild enabled turns lifetime mistakes into failing tests rather than production incidents.
Built-in Container vs Third-Party Containers#
The built-in container covers the needs of most applications, and Microsoft recommends it unless you need a feature it lacks. Mature alternatives include Autofac, DryIoc, Grace, LightInject, Lamar, Stashbox and Simple Injector.
| Need | Built-in container | Third-party container (for example Autofac) |
|---|---|---|
| Constructor injection, three lifetimes | Yes | Yes |
| Keyed or named services | Yes, since .NET 8 | Yes |
| Decorators | Manual, or via Scrutor | Usually built in |
| Assembly scanning and conventions | Via Scrutor | Built in |
| Property injection | No | Yes |
| Child containers and custom lifetimes | No | Yes |
Lazy and Func<T> factories | Manual | Usually built in |
| Framework alignment | Ships and is tested with every .NET release | Maintained separately; adds a dependency |
Most third-party containers plug in through IServiceProviderFactory<TContainerBuilder>, so framework registrations keep working:
using Autofac;
using Autofac.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(container =>
container.RegisterModule(new BillingModule()));Switch containers for a concrete missing feature, not out of habit. Replacing the provider adds an adapter layer that must keep pace with framework changes, and a second DI dialect in the codebase has a learning cost.
Best Practices#
- Prefer constructor injection and make dependencies explicit; a constructor with many parameters is a signal to split the class.
- Choose lifetimes deliberately. Default to transient or scoped, and use singletons only for thread-safe, shared state.
- Enable
ValidateScopesandValidateOnBuildin development and in integration tests. - Group registrations in
Add{Feature}extension methods that useTryAdd*, mirroring how the framework registers its own features. - Use
IServiceScopeFactoryand async scopes for per-operation work in singletons and hosted services. - Register implementation types rather than factories where possible, so the container can validate the graph.
- Keep factories synchronous and cheap, and move asynchronous initialization into startup code.
- Use
Microsoft.Extensions.DependencyInjection.AutoActivation(AddActivatedSingleton) when a singleton must be created at startup rather than on first use.
Common Pitfalls and Anti-Patterns#
- Service locator. Injecting
IServiceProviderand callingGetRequiredServiceinside business logic hides dependencies and defers missing-registration errors to runtime. Limit provider access to composition code, factories and scope creation. - Captive dependencies. Scoped services, including
IOptionsSnapshot<T>andDbContext, captured by singletons. - Resolving scoped services from the root provider, which quietly turns them into singletons that are never disposed.
- Building a second provider during registration. Calling
BuildServiceProvider()inside registration code creates duplicate singletons; ASP.NET Core flags it with analyzer ASP0000. - Blocking async factories with
.Resultor.Wait(). - Relying on registration order by accident. A later
Add*silently replaces the service used for single resolution. - Static access to the container, which makes code untestable and couples it to startup order.
Frequently Asked Questions#
What is the difference between transient, scoped and singleton in .NET?#
A transient service is created every time it is resolved, a scoped service once per scope (one HTTP request in ASP.NET Core), and a singleton once for the application's lifetime. The lifetime also controls disposal: the container disposes transient and scoped instances when the scope ends and singletons at shutdown.
How do I use a scoped service inside a singleton or BackgroundService?#
Inject IServiceScopeFactory, create a scope with CreateAsyncScope() for each unit of work and resolve the scoped service from scope.ServiceProvider. Dispose the scope with await using. For EF Core specifically, IDbContextFactory<TContext> is a simpler alternative.
When were keyed services added, and do I still need named factories?#
Keyed services arrived in .NET 8 with AddKeyedSingleton, AddKeyedScoped, AddKeyedTransient and the [FromKeyedServices] attribute. They replace most hand-written named factories. .NET 10 added key inheritance through a parameterless [FromKeyedServices].
Is injecting IServiceProvider always an anti-pattern?#
No. Factories, scope creation in hosted services and runtime selection of keyed services legitimately need the provider. It becomes the service locator anti-pattern when ordinary business classes use it to fetch their collaborators instead of declaring them in the constructor.
Should I replace the built-in container with Autofac?#
Only if you need a feature the built-in container lacks, such as property injection, child containers or advanced lifetime management. For decorators and scanning, Scrutor on top of the built-in container is usually enough.
Summary#
IServiceCollectionholds registrations;IServiceProviderresolves them, taking the last registration for single resolution and all of them forIEnumerable<T>.- Lifetimes are correctness decisions: never let a singleton capture a scoped service, and turn on scope and build validation.
- Keyed services (.NET 8+) handle variants of one abstraction, with key inheritance added in .NET 10.
- Decorators work manually with keyed services or with Scrutor; open generics and
TryAddEnumerableenable clean pipelines. - Dispose scopes asynchronously, avoid the service locator pattern and adopt a third-party container only for concrete needs.