C# developers! Your scoped components are more dangerous than you think.
For a long time, we have had a ban on using request-scoped components in our DI. We happen to be using Microsoft.Extensions.DependencyInjection
(which I'm going to call MSEDI because why not), but the reasoning is generally applicable.
Recently, we diverged from this rule, because it looked like a good solution to making various types of "request-specific" information to components, and hacked together a proof-of-concept.
However, it neatly illustrated the reason why we deprecated it in the first place.
The problem is with the permitted dependencies. Here they are:
All is well until you try to make a singleton component depend on a scoped component. That's clearly not going to work, because your singleton is created...well "at some point"...from the root container, along with its dependencies. But how do we resolve the scoped component?
Exactly what happens depends on the particular container implementation, but with MSEDI, it just silently "works". For some values of "works". There are all the right types instantiated...but not necessarily from the scope we require[1].
Imagine you are building a component that depends on a component you register from another subsystem. If you restricted yourself to singleton and transient dependencies, this will always be OK. (Providing you are OK with a constructor-injected transient in a singleton effectively becoming a singleton itself for that particular singleton - other components will get their own instance.)
However, if that subsystem has a transient that itself adds a dependency on a scoped component in its implementation without changing its public interface that is, in fact, a breaking change. And one that you will only pick up at runtime, when the components that are resolved are not the ones you expect.
So, we are probably in one of these situations:
- the person who resolved the component wasn't thinking about scopes, and so wasn't expecting a scope
- the person who resolved the component was thinking about scopes and since they are attempting to resolve a component they know to be a singleton, they were expecting there not to be a scope
- the person who resolved the component was thinking about scopes, and didn't think they thing they were resolving was a singleton because they know it has scoped dependencies, and so they were expecting that entire subtree of dependencies to belong to the current scope
Not very pleasant!
But clearly, request-scoped components are really useful. When we were discussing this internally, there were two arguments in favour of scoped components:
- AspNetCore manages to use them, and they can be useful for sharing resources that are intrinsically bound to a scope, and are expensive to create.
- It is complex, but the complexity is somewhat akin to that of async code; once you use scoped dependencies, you are "all in" on thinking about its implications. Once you make your leaf functions async, the whole stack above you must become async, and that is (broadly) true of scoped dependencies too.
Point 1 has some force. Clearly it is possible to get it to work. And very useful if you can.
But point 2 illustrates why it can be dangerous. Writing async code is hard, and everyone knows it. Whereas consuming components from a DI container is comparatively easy and everyone knows that too. If we suddenly subvert those expectations by introducing scoped components, we are far more likely to introduce subtle bugs for which developers are not on alert.
This makes it sound like a people problem, rather than a technology problem, and that is, of course, true of most development challenges. And it is not a long-term argument against using scoped components. The analogy with async is a good one - it is hard to retrofit good async practices into an existing codebase precisely because it becomes pervasive, and the same is true of scoped components. But in a greenfield project, you can engineer those practices in from the beginning.
That said, there is also a technical challenge - the containers themselves do not necessarily help us with "doing the right thing"; finding out that we are misconfigured is a subtle challenge. It leads to the big yellow warning boxes we see in the aspnetcore documentation, for example.
Now, it is not impossible to address this with run-time help. My colleague @ian.griffiths quickly crufted up this example to illustrate that container validation can "just work".
(Notice that it lacks support for e.g. factory methods, or any notion of testing - please don't use it in production code!)
This code introduces an exception at the point of configuration (usually app start-up) if we have a "surprising" dependency tree, and it gives us a (runtime) path to discovering that we were not necessarily configured the way we expected. The danger described above is limited to start-up and should be detected by the most rudimentary of smoke tests.
That approach goes a long way towards addressing my concerns. But are there other approaches to sharing scoped/contextual information that we might use?
One that I found very interesting is used by AspNetCore. There is a type in the dotnet framework called AsyncLocal
. This is akin to ThreadLocal
which you may have come across before. Instead of scoping to the current thread, it scopes to the current async context. Aspnet's IHttpContextAccessor
implementation uses an AsyncLocal
to scope the HttpContext
instance to the request. Clearly, this relies on the fact that the request itself is scoped to an async context.
With this approach, the complexity is hidden inside the component - whoever built it needs to understand the async application model quite deeply. But from a consumer's point of view, the IHttpContextAccessor
is a simple singleton, and it "just works" - there are no implementation details leaked to the outside.
On the other hand, those implementation details are really quite gnarly and, from the outside, it amounts to "a magic box"; those magic boxes are initially very attractive, but often cause future pain when the need to understand them properly arises.
So, what's my conclusion after all this narrative?
Without additional framework support for misconfigured dependencies, scoped components are too dangerous for mere mortals like ourselves. And even with that support (and an outright ban is no longer necessary!) we have to treat them with the same kind of care we would take when structuring our async code.
[1] You can actually get exceptions in some environments (e.g. resolving components during aspnetcore application startup) - but in the general case, it silently "does its best".