I get sent at least one of these a week.
System.InvalidOperationException: Cannot consume scoped service
'MyApp.Data.IUnitOfWork' from singleton 'MyApp.Reporting.ReportCache'.
Always a screenshot, always with some version of "any idea what this means?" attached.
Most developers can tell you what the three lifetimes are for. One instance forever, one per request, a new one every time. Far fewer can tell you why those three can't be freely nested inside each other, which is the thing the screenshot is actually about.
There are no lifetimes. There are three cache locations, and that exception is what you get when you put one inside another that outlives it.
Registration doesn't do anything
builder.Services.AddSingleton<IThing, Thing>();
That appends a ServiceDescriptor to a list and returns. IServiceCollection is IList<ServiceDescriptor>, the descriptor holds two types and a ServiceLifetime enum value, and nothing looks at Thing at all. Register a type with no public constructor and you won't hear about it here.
The real work happens on the first resolve, when the container turns that flat list into a graph. A node is a call site, and in the ordinary case that means a constructor plus a call site for every parameter it needs. Building the graph is mechanical, and you can skip the whole of it. What matters is what each node ends up carrying at the other end.
Lifetime goes in, a location comes out
Every call site gets a ResultCache, and its constructor is the only place in the entire container where your lifetime choice is read.
switch (lifetime)
{
case ServiceLifetime.Singleton:
Location = CallSiteResultCacheLocation.Root;
break;
case ServiceLifetime.Scoped:
Location = CallSiteResultCacheLocation.Scope;
break;
case ServiceLifetime.Transient:
Location = CallSiteResultCacheLocation.Dispose;
break;
...
}
After that switch the enum is gone. Every later decision reads the location and never asks about the lifetime again. The question stops being "how long does this live", which is vague, and becomes "which dictionary does the result go in", which has an exact answer.
There are three answers.
Root is a field on the call site object itself. One call site per registration, one field, shared by everything that resolves from that provider.
Scope is a Dictionary<ServiceCacheKey, object> hanging off a ServiceProviderEngineScope. In ASP.NET Core that scope is created on the first touch of HttpContext.RequestServices and disposed with the response.
Dispose is not a cache. Nothing is looked up and nothing is stored. You get a fresh object every time, and if it happens to be disposable the only thing recorded is an entry in the current scope's list of things to dispose later.
Three locations, one of which stores nothing. That is the whole mechanism.
Singleton means built against the root
When the container resolves a singleton, it does two things people tend not to separate. It caches the instance on the call site, and it switches the resolution context to the root scope before running the constructor.
That second part is the one that matters. Whatever scope you were in when you asked, a singleton and every dependency it pulls in are built against the root. The root scope belongs to the container itself, and it goes when the process does.
So a singleton's constructor runs once, ever, and whatever it was handed at that moment it keeps.
Scoped means built to be thrown away
A scoped service goes in the scope's dictionary. When the request ends, the scope is disposed, the dictionary goes with it, and anything in it that implemented IDisposable gets disposed on the way out. That is the entire contract, and it is why anything holding a per-request connection, transaction or identity is registered scoped. The state inside it is only meaningful for the request that created it.
Why those two can't nest
Now put them together, which is what your ReportCache did.
The singleton is built against the root, so when the container goes looking for the IUnitOfWork it needs, it is standing in the root scope rather than in a request. There is a branch for exactly that. When the scope resolving a scoped service is the root scope, the container caches it on the call site instead, which is where singletons live.
Your per-request unit of work is now a singleton. One instance, shared by every request that reaches ReportCache, disposed when the process shuts down. Its connection, its transaction and whatever identity it captured belong to whichever request happened to be first, and every request after that inherits them. Nothing resets between calls, and nothing stops two requests using it at once even though it was written on the assumption that only one ever would.
That is the failure, and it isn't a naming convention you got wrong. Nesting the two means the shorter lifetime quietly becomes the longer one, because the container has nowhere else to put it. The constructor already ran and the field is already set, so there is no later moment at which anything could swap it.
The name for this is a captive dependency.
The validator that catches it
CallSiteValidator is a second pass over the same graph, and it asks one question per node. "Is there a scoped service anywhere below this one." If the answer is yes while a singleton sits above it, it throws.
The message is built from the two service types plus nameof(ServiceLifetime.Scoped) and nameof(ServiceLifetime.Singleton) lowercased, which is why it always reads the way yours does.
That validator is why you saw an exception instead of a bug. It is also why plenty of people never see one.
Three reasons it doesn't save you
It only exists in Development. The host reads IsDevelopment() and sets ValidateScopes and ValidateOnBuild from it. In Production the validator is never constructed and every check quietly does nothing. Run staging as Staging and you are running the Production behaviour, so nobody ever sees the exception.
It cannot see inside a lambda. Register the singleton with a factory instead of a type and the validator's VisitFactory returns null. It has nothing to walk. You still get caught at runtime in Development, because the factory is handed the root scope and resolving a scoped service from the root throws its own error, but the build-time check passes clean and Production says nothing at all.
There is no transient version of it. Which is the half worth knowing about.
The same bug, with no exception attached
Transient maps to Dispose, which stores nothing, so people read it as "safe to put anywhere". It isn't.
Inject a transient into a singleton and the container builds one transient, hands it to the constructor, and never runs that constructor again. The transient is now a singleton. Whatever holds a dependency decides how long it lives, and here that is a field on an object that never goes away.
The validator does not consider this a finding. A transient below a singleton is not a scoped service below a singleton, so the walk returns null and nothing is reported, in any environment. Half the screenshots I get come from people who went looking for the transient version of the error, didn't find one, and concluded their transient must be fine.
Sandy and the two minute auth header
Me and my good friend Sandy learned this one the hard way, and it is the cleanest example of it I know.
You add a typed client and a delegating handler, using exactly the syntax the docs hand you.
services.AddTransient<AuthHeaderHandler>();
services.AddHttpClient<IPricingClient, PricingClient>()
.AddHttpMessageHandler<AuthHeaderHandler>();
AuthHeaderHandler is transient. It takes ICurrentUser, which is scoped, because the current user is obviously a per-request thing. Nothing in those two registrations is wrong and nothing anywhere complains about them.
Then look at what IHttpClientFactory does with it. AddHttpMessageHandler<T> doesn't hold your handler. It stores a callback that calls GetRequiredService<T>() later, when the factory builds the handler chain. The factory builds that chain inside a scope it creates for itself, keeps the finished chain in a dictionary keyed by client name, and reuses it for HandlerLifetime, which defaults to two minutes. It holds that scope open the whole time so the handlers inside it don't fall apart underneath it.
So your transient handler is constructed roughly once every two minutes and shared by every request that touches that client. The scoped ICurrentUser it asked for came from the factory's scope rather than from the request that happened to trigger the build, and it is pinned there for the same two minutes.
Whoever makes the first request after a handler expires decides whose auth header everybody else gets until it expires again.
There is no exception anywhere in that. The validator has nothing to object to, because the handler really was resolved from a scope. It just wasn't yours. AddTransient on the handler is not a lie either, the factory really does construct a new one each time it builds a chain, it just builds a chain far less often than you build a request.
The fix that breaks it the other way
Me and Sandy hit the mirror image of that one on the same client, and it turns up as an ObjectDisposedException.
The obvious guess is that a short-lived thing landed in a singleton and got disposed out from under it. We have just seen why that can't happen. Anything a singleton captures is promoted to the root and held until shutdown, so it is kept too long rather than let go too early.
You get the disposed exception from the fix.
The validator tells you not to inject the scoped thing, so you do what you are supposed to do. Inject IServiceScopeFactory, make a scope, take what you need.
public Task<HttpResponseMessage> GetPrices(string sku)
{
using var scope = _scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
return client.GetAsync(quot;/prices/{sku}");
}
There is no await in there, and that is the entire bug. AddHttpClient registers HttpClient itself as transient whichever overload you call, transient means cache location Dispose, and HttpClient is disposable, so the container dropped it into that scope's disposal list on the way past. The using ends, the scope disposes the client, and the request still in flight comes back to a dead object.
> Cannot access a disposed object. > Object name: 'System.Net.Http.HttpClient'.
The same method is fine if you resolve a typed client instead. The factory builds the HttpClient inside the registration lambda and hands it straight to your class, so the container never sees it and never disposes it. Which of the two you asked for decides whether this code works.
Nothing else breaks either. The factory builds every client with disposeHandler: false, so disposing one leaves the pooled handler chain and its open connections intact. Every other caller carries on. You have disposed a thin wrapper around a handler you don't own, and the wrapper is the only part that ever mentions it.
The same shape catches anything deferred out of that block. An un-awaited task, a lazy enumerable, a callback left on a timer.
Put the two together and you have the whole problem. Leave the scoped thing in the singleton and the container holds it for the life of the process. Take it out and manage the scope yourself and you hold it for slightly less time than you need it. A singleton has no scope of its own, so somebody has to decide when its dependencies die, and both exceptions are really telling you who currently has that job.
Transients that never get collected
The other transient trap is the disposal list. Resolve a transient IDisposable from the root provider in a loop and every instance is added to the root scope's list and stays there until the process exits. That is not a leak in the container. It owns what it creates, and you told it the owning scope was the one that never ends.
Other containers draw the line differently
Three lifetimes are three constants in an enum. The containers that offer more mostly got there by making the lifetime something you implement rather than a value you pick.
Autofac splits it in two. IComponentLifetime has a single method, FindScope, which picks which scope an instance belongs to, and a separate two-value InstanceSharing decides whether it is shared at all. RootScopeLifetime returns the root, CurrentScopeLifetime returns the scope you're in, and MatchingScopeLifetime walks up the chain until it finds one tagged the way you asked. Autofac scopes nest, which is what makes walking up meaningful. Microsoft's have exactly two levels, root and not-root, so there is nothing to walk.
Simple Injector does the thing I'd steal. It puts a number on each lifestyle, Length, with transient at 1, scoped at 500 and singleton at 1000, then reports a mismatch when a component's number is higher than a dependency's. That is the same captive dependency check, generalised into arithmetic, and because it is a number rather than a switch it works for lifestyles the library has never heard of. It also catches the transient case that Microsoft's validator ignores, and it catches it by default, because a singleton at 1000 is longer than a transient at 1. The opt-in UseStrictLifestyleMismatchBehavior only matters further down, where a scoped component depends on a transient one.
Castle Windsor keeps an enum but leaves Custom in it, taking an ILifestyleManager you write yourself. Ninject goes furthest and makes the scope any object at all, with singleton expressed as "scoped to the kernel" and transient as "scoped to null".
All of them plug into ASP.NET Core through IServiceProviderFactory<TContainerBuilder> and have to map Microsoft's three onto their own model first. The three are the floor, and everything else sits on top.
To finish
Your ReportCache is not broken because you picked the wrong word in a registration. It is broken because a singleton is a field that gets written once, against a scope that never ends, and you put something in it that was built to be thrown away.
The exception is the container telling you it noticed. Most of the time it doesn't.
Sources
- ResultCache.cs, dotnet/runtime
- CallSiteRuntimeResolver.cs, dotnet/runtime
- ServiceProviderEngineScope.cs, dotnet/runtime
- CallSiteValidator.cs, dotnet/runtime
- HostingHostBuilderExtensions.cs, dotnet/runtime
- RequestServicesFeature.cs, dotnet/aspnetcore
- IComponentLifetime.cs, autofac/Autofac
- Lifestyle.cs, simpleinjector/SimpleInjector
- LifestyleMismatchChecker.cs, simpleinjector/SimpleInjector
- LifestyleType.cs, castleproject/Windsor
- StandardScopeCallbacks.cs, ninject/Ninject
- DefaultHttpClientFactory.cs, dotnet/runtime
- HttpClientFactoryOptions.cs, dotnet/runtime
- HttpClientBuilderExtensions.cs, dotnet/runtime