.NET: how to consume a scoped service from a singleton?

| C#, .NET

This is a short post about a very specific C#/.NET topic. They say one needs to pick their battles. Here I decided to pick a battle with a StackOverflow answer from 2018 and a couple of fellow tech bloggers, because I have opinions. This is not exactly a tutorial, so I assume the reader has some basic understanding of .NET, its DI mechanisms and service lifetimes.

Q: In .NET, how to consume a scoped service from a singleton?

A: Just google it.

One of the first search results is a StackOverflow question. It has an accepted answer with 270+ upvotes. The answer solves the original poster's problem and is technically correct, thus having the worst type of correctness. To nobody's surprise, it's also the answer LLMs regurgitate if asked.

I believe the answer is wrong and detrimental for application architecture. I'm generally done with SO, and SO itself is in its death throes, so instead of fighting over an answer from 2018 on a dead website I'll write about it here.

The Problem

So our .NET app has two services. One is scoped and another one is singleton, and the former can't be directly consumed from the latter:

// Program.cs
builder.Service.AddSingleton<IMySingleton, MySingleton>();
builder.Service.AddScoped<IMyScoped, MyScoped>();
// elsewhere
class MySingleton: IMySingleton {
    private IMyScoped _myScoped;
    
    public MySingleton(IMyScoped myScoped) {
        _myScoped = myScoped; // Runtime error, can't consume scoped from singleton!
    }
}

You can't inject scoped dependencies into singletons, because the scoped instance lifetime is tied to its scope. The instance will be disposed as soon as its scope ends, but the singleton won't be aware of that. Still, oftentimes it's necessary to use a scoped class from a singleton, e.g. from a background service.

The solution promoted by everyone is to create a scope on demand using IServiceScopeFactory:

public class MySingleton: IMySingleton
{
    private readonly IServiceScopeFactory _scopeFactory;

    public MySingleton(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void DoWork()
    {
        using (var scope = _scopeFactory.CreateScope())
        {
            var myScoped = scope.ServiceProvider.GetRequiredService<IMyScoped>();
            myScoped.DoScopedWork();
        }
    }
}

IServiceProvider is another alternative with no meaningful difference, the result will be functionally identical:

public class MySingleton: IMySingleton
{
    private readonly IServiceProvider _serviceProvider;

    public SingletonService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void DoWork()
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var myScoped = scope.ServiceProvider.GetRequiredService<IMyScoped>();
            myScoped.DoScopedWork();
        }
    }
}

Unfortunately, both these approaches are a textbook example of the Service Locator pattern, and its disadvantages are well known:

  • IMyScoped dependency no longer arrives as a constructor param, but resolves on the method with whatever GetRequiredService has instead. This makes it difficult to swap IMyScoped implementation, either for tests or when business logic demands it. It's still possible to swap it by creating a mock of IServiceScopeFactory that would have a mocked GetRequiredService<MyScoped> returning a new implementation, but it's tedious and I'm yet to see it in the wild.
  • The dependency is now hidden. IMyScoped no longer appears in the constructor, its usage is spread across singleton methods. The example above has only one such instance, but in a real application there will be much more.

— No big deal, I need it just once and never again, so the blast radius is limited.

This first case establishes a precedent and an example for other developers. Soon your codebase will be littered with scope.ServiceProvider.GetRequiredService calls. If you won't do it, your coworkers will. If you don't want or can't rule your codebase with an iron hand, it will happen. A developer with a task and an estimate often has a narrow... scope. To complete the task, they will get it from the factory without thinking too much.

To Microsoft's credit, they actually discourage Service Locator pattern in their documentation, but do a poor job doing it. The ServiceProvider approach is ubiquituos at this point, and perpetuated so many times.

A Better Approach

My solution is to add a thin wrapper that allows scoped services to act like singletons:

class MyWrappedScoped: IMyScoped {
    private readonly IServiceScopeFactory _factory;
    
    public MyWrappedScoped(IServiceScopeFactory factory) {
        _factory = factory;
    }
    
    private TResult Forward<TResult>(Func<IMyScoped, TResult> action) {
        using var scope = _factory.CreateScope();
        var inner = scope.ServiceProvider.GetRequiredService<IMyScoped>();
        return action(inner);
    }
    
    public void DoScopedWork() {
        Forward(service => service.DoScopedWork());
    }
}

Async version is trivially doable with a pinch of async/awaits (.NET Core 3.0+):

class MyWrappedScoped: IMyScoped {
    private readonly IServiceScopeFactory _factory;
    
    public MyWrappedScoped(IServiceScopeFactory factory) {
        _factory = factory;
    }
    
    private async Task<TResult> Forward<TResult>(Func<IMyScoped, Task<TResult>> action) {
        await using var scope = _factory.CreateAsyncScope();
        var inner = scope.ServiceProvider.GetRequiredService<IMyScoped>();
        return await action(inner);
    }
    
    public async Task DoScopedWork() {
        return await Forward(service => service.DoScopedWork());
    }
}

No magic here. The wrapper does not eliminate Service Locator, but its handling is localized in the wrapper, allowing IMyScoped usage from singletons.

The solution comes with a price. First, it requires some boilerplate. If IMyScoped has a lot of methods, it can become tedious. Maybe I'll come up with automatic wiring later. Another potential drawback is that if you need to use both the wrapped and the original scoped service, DI becomes confused by which one to resolve. This can be mitigated with keyed services:

    builder.Service.AddKeyedScoped<IMyScoped, MyScoped>("scoped");
    builder.Service.AddKeyedScoped<IMyScoped, MyWrappedScoped>("wrapped");

And the Forward method now looks like this:

private TResult Forward<TResult>(Func<IMyScoped, TResult> action) {
    using var scope = _factory.CreateScope();
    var inner = scope.ServiceProvider.GetRequiredKeyedService<IMyScoped>("scoped");
    return action(inner);
}

It can then be used like this:

class MySingleton: IMySingleton {
    private IMyScoped _myScoped;
    
    public MySingleton([FromKeyedService("wrapped")] IMyScoped myScoped) {
        _myScoped = myScoped; // ok
    }
}

Not perfect, but still better than Service Locator.

Conclusion

"How to consume a scoped service from a singleton?" — use IServiceScopeFactory.

"How to consume a scoped service from a singleton without messing up my codebase?" — use the wrapper.

"Should I consume a scoped service from a singleton?" — most probably not, but sometimes there are no other options.