Search code examples
c#dependency-injectionautofac

Scoped services via delegate factories in singleton services with autofac


I've found some unexpected behavior in Autofac related to resolving scoped services via delegate factories, from within singleton services. Is the following behavior expected or am I misunderstanding something?

To avoid the Captive Dependency problem, we should not have a singleton service that takes a scoped service as a dependency. This makes perfect sense. However, if we use Autofac's delegate factories (i.e. Func<T> injection), I would expect the dependency resolved via the factory to have the configured lifetime. Instead, it always has a singleton lifetime.

To make things concrete, given the following two classes:

// a dependency that will be registered as scoped.
interface IMyScopedService
{
    Guid InstanceId { get; }
}
class MyScopedService : IMyScopedService
{
    public Guid InstanceId { get; } = Guid.NewGuid();
}

// our top-level singleton service, it has a Func<T> dependency on the above scoped service
interface IMySingletonService
{
    void PrintUsingScopedDependency();
}

class MySingletonService : IMySingletonService
{
    private readonly Func<IMyScopedService> myScopedService;

    public MySingletonService(Func<IMyScopedService> myScopedService)
    {
        this.myScopedService = myScopedService;
    }

    public void PrintUsingScopedDependency() =>
        // invoke the delegate factory to retrieve an instance of our dependency.
        Console.WriteLine(myScopedService().InstanceId);
}

and the following usage in a .NET 8 program:

using Autofac;

// container setup with a singleton service that depends on a scoped service
var builder = new ContainerBuilder();
builder.RegisterType<MySingletonService>().As<IMySingletonService>().SingleInstance();
builder.RegisterType<MyScopedService>().As<IMyScopedService>().InstancePerLifetimeScope();
var container = builder.Build();

// print using a method that invokes the Func<> to get the scoped service
using (var scope1 = container.BeginLifetimeScope())
{
    var singleton = scope1.Resolve<IMySingletonService>();
    // should print the same guid twice because it's the same scope. This works.
    singleton.PrintUsingScopedDependency();
    singleton.PrintUsingScopedDependency();
}

// same as above, but a different scope
using (var scope2 = container.BeginLifetimeScope())
{
    var singleton = scope2.Resolve<IMySingletonService>();
    // should print another Guid twice
    // HOWEVER -- it prints the same Guid as the first scope
    singleton.PrintUsingScopedDependency();
    singleton.PrintUsingScopedDependency();
}

I would expect the program to print two different guids, each printed twice:

84b369d6-43bb-48ce-8c64-66d90e4d64d4
84b369d6-43bb-48ce-8c64-66d90e4d64d4
63011a0f-5cc2-4566-a9ae-0d6b35e1ed6e
63011a0f-5cc2-4566-a9ae-0d6b35e1ed6e

Instead, it prints the same guid 4 times:

84b369d6-43bb-48ce-8c64-66d90e4d64d4
84b369d6-43bb-48ce-8c64-66d90e4d64d4
84b369d6-43bb-48ce-8c64-66d90e4d64d4
84b369d6-43bb-48ce-8c64-66d90e4d64d4

Is there a reason the delegate factory doesn't use the configured lifetime? I understand the Captive Dependency issue, but it doesn't seem like it should apply to delegate factories.


Solution

  • The behavior is expected, as any single instance service will always be created and resolved from the root scope, from where no other current or future scopes can be seen. Furthermore, any dependencies this service might have will also have to be resolved from the root. Thus, your factory delegate will be resolved from the root and handed to IMySingletonService, and because of this, the factory delegate will never see any other scope than the root.

    Consider this: as you make the scope1.Resolve<IMySingletonService>(); call, even though making the Resolve call on the scope1 instance, because IMySingletonService is registered as single instance, the resolution have to "climb up" to the root scope, create the instance (because it doesn't exist yet) and store it there, and then return this instance from the Resolve call.

    Next, as you make the scope2.Resolve<IMySingletonService>();, again the resolution have to "climb up" to the root scope. At this point, both the IMySingletonService instance (and its factory delegate instance) already exist in the root scope, and this instance is therefore grabbed and returned from the Resolve call.

    This is fairly straight forward and is how Autofac ensure that a "single instance" service is only created once and that instance will be reused throughout the lifetime of the root container.

    The point to realize then is that the factory delegate, now owned by IMySingletonService, will only be able to resolve instances from the root because any child scope (scope1 and scope2) is not visible to it. Using the factory to resolve IMyScopedService from the root will create one instance of MyScopedService and store it in the root scope, effectively making it a single instance.