DEV Community

Anton
Anton

Posted on

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

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>();
Enter fullscreen mode Exit fullscreen mode
// elsewhere
class MySingleton: IMySingleton {
    private IMyScoped _myScoped;

    public MySingleton(IMyScoped myScoped) {
        _myScoped = myScoped; // Runtime error, can't consume scoped from singleton!
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

It can then be used like this:

class MySingleton: IMySingleton {
    private IMyScoped _myScoped;

    public MySingleton([FromKeyedService("wrapped")] IMyScoped myScoped) {
        _myScoped = myScoped; // ok
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


Originally posted on my website.

Top comments (0)