Dependency injection
How ASP.NET Core creates objects and hands them their dependencies: why you need a container, how service lifetimes differ, where captive dependencies and leaks come from, and when the built-in container is not enough. Lifetime bugs often show up only under load, which is why interviewers like to ask about them.
Before this topic: The C# language.
What is dependency injection and why do you need a container?
Section titled “What is dependency injection and why do you need a container?”What it is
With dependency injection a class does not create its dependencies; it receives them as constructor parameters. A dependency is any object the class uses: a repository, an HTTP client, a logger.
Why
- You can replace the implementation without touching the class:
SmtpEmailSenderforFakeEmailSenderin a test. - The code that creates objects lives in one place instead of being spread across the app.
How to use
- Declare the dependency as a constructor parameter:
public OrderService(IEmailSender sender). - Register the implementation in
builder.Services:AddScoped<IEmailSender, SmtpEmailSender>(). - The ASP.NET Core container creates
OrderServiceand passesSmtpEmailSenderinto it.
Common mistake
Creating a dependency with new inside the class. Then you cannot replace it in a test, and the container will not dispose of it on time.
Takeaway: the class asks for dependencies in its constructor; the container creates and wires them.
How it works
- DIP. The high-level
OrderServiceand the low-levelSmtpEmailSenderboth depend on theIEmailSenderabstraction. The abstraction is defined by the code that uses it. - Registration.
IServiceCollectionis a list of registrations: service type, implementation, lifetime.builder.Build()turns it into anIServiceProvider. - Resolution. The container picks the public constructor with the most parameters it can create and recursively creates each parameter. If several constructors qualify and the choice is ambiguous, it throws.
When to choose what
| Way to get a dependency | When |
|---|---|
| Constructor parameter | almost always |
Minimal API handler parameter, [FromServices] in an action |
only one endpoint needs it |
IServiceProvider.GetService |
infrastructure code: factories, wrappers |
Pitfalls
- Service Locator. An injected
IServiceProviderandGetRequiredServiceinside methods hide dependencies. The constructor no longer tells you what the class needs, and a missing registration fails only when the method runs. - Static access.
app.Servicesstored in a static field is the same locator, only global.
Rule of thumb
Dependencies go into the constructor, implementations into registrations in Program.cs. GetService in business code is a reason to refactor.
Takeaway: the container builds the object graph from registrations; the class only lists what it needs in its constructor.
Under the hood
- After
builder.Build()the registration collection becomes read-only: the graph is fixed at startup. - The first time a service is requested, the container builds a creation plan (a call site) and caches it. Frequently used plans are compiled into delegates, so later resolutions are cheap.
- A cycle A → B → A is detected while the plan is built:
InvalidOperationException: A circular dependency was detected.
Trade-offs
- Constructor versus locator. Constructor injection makes dependencies explicit and verifiable. A locator is more flexible but turns a startup error into an error in the middle of a request.
- Abstractions are not free. An interface for every class with a single implementation is noise. You need an interface where there is a seam: an external system, a test substitute, several implementations.
- Long constructor. A dozen parameters signals an SRP violation. DI does not create the problem; it makes it visible.
- Not everything is a service. Entities, DTOs and request data are created with
new. The container holds objects with behaviour.
How to check in production
- A configuration error appears in the log as
Unable to resolve service for type 'X' while attempting to activate 'Y'. - In code review, look for
IServiceProviderin business-class constructors and for static references to the provider.
Takeaway: DI makes dependencies explicit and composes the graph in one place; anything that hides them brings back the problems DI solved.
var builder = WebApplication.CreateBuilder(args);
// Registration: abstraction → implementationbuilder.Services.AddScoped<IEmailSender, ConsoleEmailSender>();builder.Services.AddScoped<OrderService>();
var app = builder.Build();
// The container creates OrderService and passes ConsoleEmailSender into itapp.MapPost("/orders/{id:int}", (int id, OrderService orders) => orders.Place(id));
app.Run();
public interface IEmailSender { void Send(string text); }
public class ConsoleEmailSender : IEmailSender{ public void Send(string text) => Console.WriteLine(text);}
// The dependency arrives through the constructor: the class does not know the implementationpublic class OrderService(IEmailSender sender){ public void Place(int id) => sender.Send($"Order {id} placed"); // Order 42 placed}
// Service Locator anti-pattern: the constructor does not show what the class needspublic class BadOrderService(IServiceProvider services){ public void Place(int id) => services.GetRequiredService<IEmailSender>().Send($"Order {id}");}- How does DI differ from IoC? — IoC is the general principle “the framework is in control”; DI is one way to implement it.
- Why is Service Locator considered an anti-pattern? — It hides dependencies, moves errors to run time and makes testing harder.
- Which constructor does the container pick if there are several? — The public one with the most resolvable parameters; if that is ambiguous, it throws.
- Are interfaces mandatory? — No, the container injects concrete classes too; an interface is needed where you need substitution.
- Does the built-in container support property injection? — No, only constructor injection;
[Inject]in Blazor components and parameters of Minimal API handlers, actions andInvokeAsyncare handled by the framework itself.
What is the difference between Singleton, Scoped and Transient?
Section titled “What is the difference between Singleton, Scoped and Transient?”What it is
| Lifetime | When a new object is created |
|---|---|
AddTransient |
on every injection |
AddScoped |
once per scope — per HTTP request in a web app |
AddSingleton |
once for the whole app |
What a scope is
A scope is the area inside which a scoped service exists as a single instance. ASP.NET Core opens a scope at the start of a request and closes it at the end. A controller, a service and a repository in the same request get the same DbContext.
How to use
- Lightweight object without state — Transient.
- Per-request state:
DbContext, the current user — Scoped. - Expensive to create and shared: an in-memory cache — Singleton.
Common mistake
Storing request data in a singleton, for example in a CurrentUser field. Every concurrent request will see it.
Takeaway: Transient is new every time, Scoped is one per request, Singleton is one per app.
How it works
builder.Build()creates the root provider. Singleton services are stored in it until the app stops.- For each request ASP.NET Core creates a scope and puts its provider into
HttpContext.RequestServices. Controllers, handlers and their dependencies are created from it. - A scoped service is created on first use in that scope and reused after that. A transient is created on every resolution.
- At the end of the request the scope is disposed: the container calls
Disposeon theIDisposableobjects created in it.
Who can depend on whom
| Service | Safe dependencies |
|---|---|
| Singleton | Singleton; Transient without state or resources |
| Scoped | any |
| Transient | any |
Pitfalls
- Scoped from the root.
app.Services.GetRequiredService<ScopedService>()outside a scope effectively makes the service a singleton: it lives until shutdown. In Development this throws. - State in a singleton. Its fields are read and written by concurrent requests at the same time — you need synchronization or immutability.
Rule of thumb
Services with data access — Scoped. Singleton — only for state that is expensive to create or truly global, and then it must be thread-safe. Transient — for lightweight stateless objects.
Takeaway: the lifetime decides who shares an instance: the request, the app or nobody.
Under the hood
- A scope stores the scoped instances created in it and a list of objects to dispose of. Scopes are not hierarchical: a new scope inherits nothing from the current one; they share only singletons from the root.
- A singleton is created lazily, on first request. The container calls its factory exactly once and on a single thread, even under concurrent requests.
- The host defines the scope boundary: an HTTP request in MVC and Minimal API, a circuit (the user’s connection) in Blazor Server, a manual scope in background work.
Cost
- Transient means an allocation and a constructor call on every resolution, including all transient dependencies. On a hot path a heavy graph is noticeable.
- A singleton keeps its graph until shutdown: whatever it has accumulated, the GC will not reclaim.
- A heavy constructor of a scoped service is paid on every request.
Trade-offs
- Microsoft recommends singletons only for services with their own expensive or global state. Everything a singleton holds becomes shared, including accidentally captured dependencies.
- Per-user state does not belong to any service lifetime: use a session, a cache or a database.
- Scoped in Blazor Server lives orders of magnitude longer than an HTTP request, so
DbContextis obtained there throughIDbContextFactory<T>.
How to check in production
- The instance count of your services in
dotnet-gcdumpgrows under steady traffic — something holds them in a singleton or in the root provider. - The allocation rate in
dotnet-countersbefore and after a lifetime change shows the cost of transient graphs.
Takeaway: a lifetime is a trade-off between shared state, thread safety and allocations, and the host defines the scope boundary.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<SingletonDep>();builder.Services.AddScoped<ScopedDep>();builder.Services.AddTransient<TransientDep>();builder.Services.AddTransient<Consumer>();
var app = builder.Build();
// Two consumers in the same requestapp.MapGet("/", (Consumer a, Consumer b) => new{ singleton = a.Singleton.Id == b.Singleton.Id, // true: one per app scoped = a.Scoped.Id == b.Scoped.Id, // true: one per request transient = a.Transient.Id == b.Transient.Id // false: new on every injection});
app.Run();
public class Dep { public Guid Id { get; } = Guid.NewGuid(); }public class SingletonDep : Dep { }public class ScopedDep : Dep { }public class TransientDep : Dep { }
public record Consumer(SingletonDep Singleton, ScopedDep Scoped, TransientDep Transient);On the next request Scoped.Id will be different, while Singleton.Id stays the same.
- What happens if you request a scoped service from
app.Services? — In Development it throws; otherwise it lives until the app stops, like a singleton. - Where is the provider of the current request stored? — In
HttpContext.RequestServices. - How does a scope in Blazor Server differ from a scope in Web API? — It lives for the whole user connection (circuit), not a single request.
- Must a singleton be thread-safe? — Yes, all requests use it at the same time; the container guarantees only that it is created once.
- What lifetime does
DbContextget fromAddDbContext? — Scoped.
What is a captive dependency and how do you catch it?
Section titled “What is a captive dependency and how do you catch it?”What it is
A captive dependency is a long-lived service holding a short-lived service in a field. The classic case: a singleton receives a scoped repository with a DbContext in its constructor.
Why it is bad
A singleton’s constructor runs once, so the repository inside it is also one for the whole app:
DbContextis not thread-safe, and concurrent requests corrupt its state;- the entities it tracks pile up for the whole lifetime of the app.
How to catch it
In Development ASP.NET Core checks registrations at startup and fails with Cannot consume scoped service ... from singleton .... Do not turn this check off.
Common mistake
“Fixing” the exception by making the repository a singleton. The error disappears from the log, but the problem stays.
Takeaway: a service must not depend on services that live shorter than it does.
How it works
| Option | What it checks | When |
|---|---|---|
ValidateScopes |
scoped is not resolved from the root and not injected into a singleton | at resolution |
ValidateOnBuild |
every registration can be created | at builder.Build() |
Together they catch scoped-in-singleton already at startup. WebApplication.CreateBuilder enables both in the Development environment; in others use builder.Host.UseDefaultServiceProvider.
Pitfalls
- Transient in a singleton. The container does not check it. A typed
HttpClientis transient: captured by a singleton, it stops seeing DNS changes. - Factories. In
AddSingleton(sp => new X(sp.GetRequiredService<Repo>()))the dependency is hidden in a lambda, andValidateOnBuilddoes not see it.ValidateScopescatches it only whenXis first created. - Middleware. Conventional middleware is created once. Take a scoped service as an
InvokeAsyncparameter, not a constructor parameter. - Open generics.
AddScoped(typeof(IRepository<>), typeof(Repository<>))is not checked duringBuild().
Rule of thumb
A dependency lives at least as long as its consumer: a singleton accepts only singletons. A singleton gets scoped services through its own scope.
Takeaway: the container catches scoped-in-singleton; transient captures, factories and open generics are up to you.
Under the hood
- For each registration the container builds a call-site tree — a creation plan. With
ValidateScopesthe validator looks for scoped nodes in a singleton’s tree and forbids resolving scoped services from the root. ValidateOnBuildbuilds a plan for every closed registration duringBuild()and collects errors into anAggregateException. It checks the scoped/singleton rule only ifValidateScopesis on too.- A factory is an opaque node in the plan: what it resolves inside is unknown until it runs.
Cost
- A captured
DbContextgives youInvalidOperationException: A second operation started on this context before a previous operation completed, stale data and memory growth. - A captured typed
HttpClientkeeps a singleHttpMessageHandlerand misses DNS changes. - The failures are intermittent: they appear under concurrent load, not in tests with a single request.
Trade-offs
ValidateOnBuildin Production lengthens startup in proportion to the number of registrations. A cheaper option is an integration test that builds the host with both checks and fails in CI.- What the container cannot see is covered by convention: a singleton accepts only singletons,
IServiceScopeFactoryor factories such asIHttpClientFactoryandIDbContextFactory<T>.
How to check in production
- Symptoms: concurrent-access errors on
DbContextunder load, one user’s data in another user’s response, memory growth. - A dump (
dotnet-dump, thegcrootcommand) shows that theDbContextis held by a singleton service from the root provider.
Takeaway: validation catches only scoped-in-singleton and only what it can see; the rest is covered by a test with validation enabled and the rule “singletons depend on singletons”.
var builder = WebApplication.CreateBuilder(args);
// Outside Development the checks are off: enable them in every environmentbuilder.Host.UseDefaultServiceProvider(options =>{ options.ValidateScopes = true; options.ValidateOnBuild = true;});
builder.Services.AddScoped<CartRepository>();builder.Services.AddSingleton<PriceService>();
// AggregateException: ... Cannot consume scoped service 'CartRepository'// from singleton 'PriceService'.var app = builder.Build();app.Run();
public class CartRepository{ public decimal GetTotal() => 100m;}
// Captive dependency: the singleton holds the scoped repository forever.// Fix: make PriceService scoped or take the repository from its own scope.public class PriceService(CartRepository repository){ public decimal Total() => repository.GetTotal();}- Will the container catch a transient inside a singleton? — No, it is legal from its point of view; you have to watch for it yourself.
- Why does the app fail in Development but work in Production? —
ValidateScopesandValidateOnBuildare on by default only in Development. - How do you use a scoped service in middleware? — As an
InvokeAsyncparameter: the constructor of conventional middleware runs once. - Can you inject
IOptionsSnapshot<T>into a singleton? — No, it is scoped; singletons useIOptionsMonitor<T>. - What does
ValidateOnBuildnot check? — Open generic registrations and dependencies that a factory resolves internally.
How do you use a scoped service from a singleton or a background task?
Section titled “How do you use a scoped service from a singleton or a background task?”What it is
A background service and a singleton are created once, outside any HTTP request. They have no request scope, and you cannot inject a scoped service into their constructor: in Development that throws.
How to use
- Inject
IServiceScopeFactoryinto the constructor — it is a singleton, so that is allowed. - For each unit of work create a scope:
await using var scope = scopeFactory.CreateAsyncScope();. - Get the service from it:
scope.ServiceProvider.GetRequiredService<AppDbContext>(). - At the end of the block the scope is disposed of together with the
DbContext.
Common mistake
Creating one scope before an infinite loop. Then the DbContext lives for the whole lifetime of the app, like a singleton.
Takeaway: outside a request you create scopes manually — one per unit of work.
How it works
IServiceScopeFactoryis registered as a singleton and creates scopes off the root provider.scope.ServiceProvideris that scope’s provider: scoped services are shared inside it, transients are created anew, singletons come from the root.DisposeorDisposeAsyncon the scope disposes of everything created in it.
When to choose what
| Option | When |
|---|---|
CreateAsyncScope() with await using (.NET 6+) |
by default |
CreateScope() with using |
synchronous code, every service implements IDisposable |
IDbContextFactory<T> |
you need only a DbContext; you dispose of it yourself |
Pitfalls
- Synchronous disposal. If a service implements only
IAsyncDisposable,scope.Dispose()throwsInvalidOperationException. - One scope for the whole loop. Tracked entities pile up, and a failure in one iteration corrupts the context for the next ones.
- Request services in the background. A task that captured services from
HttpContext.RequestServicesoutlives the request and getsObjectDisposedException. Pass data to background work and resolve services from a new scope. - Parallel tasks. One scope shared by several threads means one
DbContextfor all of them. Give each task its own scope.
Rule of thumb
One scope — one unit of work: a message from a queue, a timer tick, a job.
Takeaway: IServiceScopeFactory and CreateAsyncScope per unit of work is the standard way to use a scoped service outside a request.
Under the hood
AddHostedServiceregisters a hosted service as a singleton, so its constructor runs once in the root provider.- A scope stores the objects created in it and on
DisposeAsyncdisposes of them in reverse order of creation:DisposeAsyncif available, otherwiseDispose. - Scopes are not hierarchical: a background task’s scope does not see the request’s scoped services, even if it was created during that request.
Cost
The scope itself is cheap; you pay for the services inside it. For DbContext, pooling (AddDbContextPool) lowers the creation cost.
Trade-offs
| Scope granularity | Pro | Con |
|---|---|---|
| Per message | failure isolation, fresh context | services created for every message |
| Per batch | less overhead, one transaction | one failure rolls back the whole batch |
| Whole loop | least code | hidden singleton, memory growth |
IDbContextFactory<T> is handy in singletons and Blazor Server, but it bypasses the scope: other scoped services will not see that context.
How to check in production
dotnet-counters monitor --counters Microsoft.EntityFrameworkCoreshows the number of activeDbContextinstances. If it keeps growing, contexts are not being disposed of.- Memory growth of a background process under steady load is a reason to check where the scope is created.
Takeaway: a scope is the boundary of a unit of work; its granularity defines failure isolation, memory use and the cost of creating services.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<OrderStore>(); // like DbContext from AddDbContextbuilder.Services.AddHostedService<OutboxWorker>(); // hosted service — singleton
var app = builder.Build();app.Run();
public class OutboxWorker(IServiceScopeFactory scopeFactory) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { // A new scope for each iteration: a fresh OrderStore await using (var scope = scopeFactory.CreateAsyncScope()) { var store = scope.ServiceProvider.GetRequiredService<OrderStore>(); await store.SendPendingAsync(stoppingToken); } // OrderStore.DisposeAsync() is called here
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } }}
public sealed class OrderStore : IAsyncDisposable{ public Task SendPendingAsync(CancellationToken ct) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;}- Why can’t you inject
DbContextinto aBackgroundServiceconstructor? — A hosted service is a singleton, so that is a captive dependency; in Development it throws. - Why is
CreateAsyncScopebetter thanCreateScope? — It disposes ofIAsyncDisposableservices viaDisposeAsync; synchronousDisposethrows for them. - How do you pass request data to background work? — As a copy of the data (an id, a DTO), not as services from
RequestServices: those are disposed of with the request. - When is
IDbContextFactory<T>more convenient than a scope? — When you need only aDbContextor several units of work inside one scope, as in Blazor Server. - Does a new scope inherit the current request’s services? — No, scopes are not hierarchical.
How do you register several implementations of one interface and pick the right one?
Section titled “How do you register several implementations of one interface and pick the right one?”What it is
An interface can have several implementations: EmailNotifier and SmsNotifier for INotifier. Both are registered with a regular AddScoped or AddSingleton.
How to use
- All at once. Inject
IEnumerable<INotifier>— you get every implementation in registration order. That is how you notify through all channels. - The last one. Inject
INotifier— you get the last registered one. - By key (.NET 8+). Register
AddKeyedScoped<INotifier, SmsNotifier>("sms")and inject[FromKeyedServices("sms")] INotifier sms.
Common mistake
Expecting the second registration to replace the first. It is added to the list, and IEnumerable<T> will contain both.
Takeaway: IEnumerable<T> gives all implementations, T the last one, a key a specific one.
How it works
IServiceCollection is a list of ServiceDescriptor: the service type, the implementation or factory, the lifetime and, since .NET 8, a key.
| Method | What it does |
|---|---|
Add{Lifetime} |
always adds a registration |
TryAdd{Lifetime} |
adds if the service has no registrations yet |
TryAddEnumerable |
adds if the same service-and-implementation pair is not there |
Replace, RemoveAll |
replaces or removes the service’s registrations |
When to choose what
- Handlers, validators, plugins —
IEnumerable<T>registered withTryAddEnumerable, so a second call toAddFeature()does not duplicate them. - A default in a library —
TryAdd: if the app has already registered its own implementation, the library will not override it. - A strategy chosen by a value: a payment provider, storage per region — keyed services,
[FromKeyedServices(key)]orGetRequiredKeyedService<T>(key). - A wrapper around an implementation: caching, logging, retries — a decorator.
Pitfalls
- Keyed registrations do not show up in a regular
IEnumerable<T>and are not resolved without a key. - The decorator
AddScoped<IRepo>(sp => new CachedRepo(sp.GetRequiredService<IRepo>()))loops forever: the factory resolves itself. Register the inner implementation under its concrete type. TryAddafterAddfor the same service does nothing: call order matters.
Rule of thumb
Equal implementations — IEnumerable<T>; a choice by value — a key or a strategy factory; a wrapper — a decorator.
Takeaway: registrations are an ordered list: Add appends, TryAdd respects what is already there, a key picks a specific implementation.
Under the hood
- For
Tthe container takes the last registration, forIEnumerable<T>all of them in the order added. Each element has its own lifetime: oneIEnumerable<T>can mix singletons and transients. - A key is any object with a correct
Equals.KeyedService.AnyKeyregisters a fallback implementation for any key, and the[ServiceKey]attribute passes the key into the implementation’s constructor. - Since .NET 10,
GetKeyedServicewithKeyedService.AnyKeythrowsInvalidOperationException:AnyKeyis not meant for resolving a single service.
Trade-offs
| Way to pick an implementation | Pro | Con |
|---|---|---|
| Keyed services | built in, no extra code | key not checked by the compiler, choice sits with the consumer |
| Strategy factory | choice in one place, easy to test | your own code and registration |
IEnumerable<T> and a Name property |
new implementation without changing a factory | a scan on every choice |
A factory-based decorator is explicit, but you call constructors by hand. Scrutor (Decorate<TService, TDecorator>()) replaces a registration that already exists, so the call must come after it.
How to check in production
- An integration test pins down which implementation is actually injected: build the host and check the type returned by
GetRequiredService<T>(). - An event handled twice is a sign of
Addinstead ofTryAddEnumerablein an extension method that was called twice.
Takeaway: choosing an implementation is a design decision — enumeration, key or factory; decorators in the built-in container are hand-written or come from Scrutor.
using Microsoft.Extensions.DependencyInjection.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<INotifier, EmailNotifier>();builder.Services.AddSingleton<INotifier, SmsNotifier>();// Adds nothing: the INotifier + EmailNotifier pair is already therebuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<INotifier, EmailNotifier>());// Keyed services (.NET 8+): an implementation by keybuilder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");builder.Services.AddSingleton<Dispatcher>();
var app = builder.Build();app.MapGet("/", (Dispatcher d) => d.Describe()); // "email,sms | sms | sms"app.Run();
public interface INotifier { string Name { get; } }public class EmailNotifier : INotifier { public string Name => "email"; }public class SmsNotifier : INotifier { public string Name => "sms"; }
public class Dispatcher( IEnumerable<INotifier> all, // email, sms — in registration order INotifier last, // sms — the last registration [FromKeyedServices("sms")] INotifier byKey) // sms — by key{ public string Describe() => $"{string.Join(",", all.Select(n => n.Name))} | {last.Name} | {byKey.Name}";}var builder = WebApplication.CreateBuilder(args);
// The inner implementation goes under its concrete type, the interface goes to the decoratorbuilder.Services.AddScoped<SqlOrderRepository>();builder.Services.AddScoped<IOrderRepository>(sp => new LoggingOrderRepository( sp.GetRequiredService<SqlOrderRepository>(), sp.GetRequiredService<ILogger<LoggingOrderRepository>>()));// With Scrutor: AddScoped<IOrderRepository, SqlOrderRepository>(),// then Decorate<IOrderRepository, LoggingOrderRepository>()
var app = builder.Build();app.MapGet("/orders/{id:int}", (int id, IOrderRepository repo) => repo.Find(id));app.Run();
public interface IOrderRepository { string Find(int id); }
public class SqlOrderRepository : IOrderRepository{ public string Find(int id) => $"order-{id}";}
public class LoggingOrderRepository(IOrderRepository inner, ILogger<LoggingOrderRepository> logger) : IOrderRepository{ public string Find(int id) { logger.LogInformation("Find {Id}", id); return inner.Find(id); }}- What does an
INotifierconstructor parameter receive if two implementations are registered? — The last registered one. - How does
TryAdddiffer fromTryAddEnumerable? — The first checks only the service type, the second the service-and-implementation pair. - Do keyed registrations show up in
IEnumerable<T>? — No, only regular ones; keyed services are requested with a key. - How does a keyed implementation get its key? — Through a constructor parameter marked with
[ServiceKey]. - How do you build a decorator without Scrutor? — Register the inner implementation under its concrete type and compose the decorator in a factory.
Who disposes of services, why are factories risky and when do you need a third-party container?
Section titled “Who disposes of services, why are factories risky and when do you need a third-party container?”What it is
A service often holds a resource: a connection, a file, a socket. Releasing it is the container’s job, not your code’s.
How it works
| Registration | When the container calls Dispose |
|---|---|
| Transient, Scoped | at the end of the scope, in web apps at the end of the request |
| Singleton | when the app stops |
AddSingleton(new X()) |
never: you created the instance |
How to use
- Do not call
Disposeor writeusingfor injected services. - If your service holds a resource, implement
IDisposable— the container will callDispose.
Common mistake
Wrapping an injected DbContext in using. After the block other services in the same request get a disposed context and ObjectDisposedException.
Takeaway: whoever creates an object disposes of it: what the container created, the container disposes of.
How it works
- The container records every object it creates that implements
IDisposableorIAsyncDisposablein its scope’s list. Other objects are not recorded. - When the scope closes, it disposes of them in reverse order of creation: a consumer before its dependencies.
- The result of a factory
sp => new X()also counts as created by the container and is disposed of.
Pitfalls
- Disposable transient from the root.
app.Services.GetRequiredService<Connection>()in a loop: every instance stays in the root’s list until shutdown. - A factory returns someone else’s object.
AddTransient<IFoo>(_ => sharedFoo)— the container disposes ofsharedFooat the end of the very first scope. - Async in a factory. Factories are synchronous, and
GetAsync().Resultinside one is sync-over-async with a deadlock risk. Move async initialization into a separate method or anIHostedService.
When to choose what
| Need | Solution |
|---|---|
| A resource to release before the scope ends | your own factory with a Create method and using at the call site |
| A shared resource for a limited time | a scoped service and an explicit scope |
| A resource for the whole app | a singleton created by the container |
Rule of thumb
Do not register an IDisposable as transient if it will be resolved outside a short-lived scope.
Takeaway: the container disposes of everything it created, so disposable transients in the root and factories returning someone else’s objects break ownership.
What the built-in container lacks
Microsoft lists it explicitly: property injection, child containers, custom lifetime management, Func<T> support for lazy initialization, convention-based registration. Add decorators and interceptors. Named registrations were covered by keyed services in .NET 8.
When to choose what
| Task | Solution |
|---|---|
| Assembly scanning, decorators | Scrutor on top of the built-in container |
Modules, Owned<T>, Func<T>, tagged scopes, interceptors |
Autofac |
| Everything else | the built-in container |
Trade-offs
- Autofac is plugged in with
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()). It takes over the registrations frombuilder.Services, butValidateScopesandValidateOnBuildno longer apply. - Autofac’s documentation says outright that it does not always protect you from captive dependencies.
- Assembly scanning and interceptors mean reflection at startup and registrations you cannot find by searching the code.
How to check in production
- A disposable-transient leak shows up in
dotnet-gcdump: the instance count of the service grows, and the retention path leads to the rootServiceProvider. - In code review, look for
GetServiceonapp.Servicesoutside a scope and for.Resultin registration factories.
Takeaway: whoever creates an object disposes of it; adopt a third-party container for a specific feature, remembering that the built-in provider’s checks go away.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<Connection>();builder.Services.AddSingleton(new Cache()); // not created by the container: no Disposebuilder.Services.AddSingleton(sp => new Pool()); // created by a factory: disposed of at shutdown
var app = builder.Build();
// Leak: a transient IDisposable from the root lives until the app stopsfor (int i = 0; i < 1_000; i++) app.Services.GetRequiredService<Connection>();
// Correct: the scope disposes of everything created in itawait using (var scope = app.Services.CreateAsyncScope()){ scope.ServiceProvider.GetRequiredService<Connection>();} // Connection.Dispose()
app.Run();
public sealed class Connection : IDisposable{ public void Dispose() => Console.WriteLine("Connection.Dispose()");}public sealed class Cache : IDisposable { public void Dispose() { } }public sealed class Pool : IDisposable { public void Dispose() { } }- Does the container dispose of an object from
AddSingleton(new X())? — No, you created it; an object from the factoryAddSingleton(sp => new X())is disposed of. - In what order are a scope’s services disposed of? — In reverse order of creation.
- Why is a disposable transient from the root a leak? — The container keeps a reference to every instance until the app stops.
- How do you create an object with dependencies from the container but own it yourself? —
ActivatorUtilities.CreateInstance<T>(provider)andusingat the call site. - What do you lose when switching to Autofac? — The built-in provider’s
ValidateScopesandValidateOnBuild.