simpleinjector / SimpleInjector

An easy, flexible, and fast Dependency Injection library that promotes best practice to steer developers towards the pit of success.
https://simpleinjector.org
MIT License
1.22k stars 152 forks source link

Sharing Instances between Parent and child scopes #992

Closed wleader closed 8 months ago

wleader commented 8 months ago

I think I may have an unusual use case, and I think maybe a custom lifestyle could do what I want but I am not sure if that is the right approach or how to do it. The Code below I hope will illustrate what I am trying to accomplish. First I will try to explain why I need to do what I am trying to do.

Assume that there is a service, and that this service behaves like a multi-threaded program. (Its not really Traditional threads, its all Async/Await). Inside this process there are multiple parent workers. The parent worker connects to the database, starts a transaction, finds a piece of data to work on, and then calls a child task to do its work on that data. The child task must utilize the same database connection and transaction as the parent task so the changes to the database that the child makes will be included in the transaction that the parent started. Finally the parent commits the transaction and runs the loop again.

Without going into distracting complexity, The child task may be different classes each time a unit of work is handled, and are not even known at compile time. They will come from a different assembly with their rules for matching child task instances to the data found by the parent, So the child task instances must be created on demand. To be more clear the parent task is in library code, and the child task is in the application that is consuming the library code. I don't really have an option to deviate from this pattern.

What would be the right way of creating multiple parent tasks with their own scopes, that create child tasks in their own child scopes, and where the child scope would return the same shared object as its respective parent?

Thank you for any guidance you can provide

using SimpleInjector;
using SimpleInjector.Lifestyles;
using System.Diagnostics;

namespace TestApp
{
    class ScopedObject { }

    class SharedObject 
    {
        public Task SetupConnection() => Task.CompletedTask;
        public Task BeginTransaction() => Task.CompletedTask;
        public Task ParentUtilizeConnection() => Task.CompletedTask;
        public Task ChildUtilizeConnection() => Task.CompletedTask;
        public Task EndTransaction() => Task.CompletedTask;
    }

    class ChildTask(ScopedObject scopedObject, SharedObject sharedObject)
    {
        public ScopedObject ScopedObject { get; } = scopedObject;
        public SharedObject SharedObject { get; } = sharedObject;

        public async Task Run()
        {
            await SharedObject.ChildUtilizeConnection();
        }
    }

    class ParentTask(ScopedObject scopedObject, SharedObject sharedObject)
    {
        public ScopedObject ScopedObject { get; } = scopedObject;
        public SharedObject SharedObject { get; } = sharedObject;

        public ChildTask ChildTask { get; private set; } = default!;

        public async Task Run(Container container)
        {
            await SharedObject.SetupConnection();
            //while(serviceRunning) // typically this happens in a loop but not looping for inspection and debugging.
            {
                await SharedObject.BeginTransaction();
                await SharedObject.ParentUtilizeConnection();
                var innerScope = AsyncScopedLifestyle.BeginScope(container);

                ChildTask = innerScope.GetInstance<ChildTask>();

                await ChildTask.Run();

                // normally would dispose when done, but so the object can stay alive,
                // for inspection and debugging.
                //innerScope.Dispose();
                await SharedObject.EndTransaction();
            }
        }
    }

    internal class Program
    {
        static async Task Main()
        {
            var container = new Container();
            container.Options.DefaultScopedLifestyle = new AsyncScopedLifestyle();

            container.Register(typeof(ChildTask), typeof(ChildTask), Lifestyle.Scoped);
            container.Register(typeof(ParentTask), typeof(ParentTask), Lifestyle.Scoped);
            container.Register(typeof(ScopedObject), typeof(ScopedObject), Lifestyle.Scoped);

            // could my desired outcome be achieved with a custom lifestyle?
            //container.Register(typeof(SharedObject), typeof(SharedObject), new CustomLifestyle());
            container.Register(typeof(SharedObject), typeof(SharedObject), Lifestyle.Scoped);

            var outerScope1 = AsyncScopedLifestyle.BeginScope(container);
            var parent1 = outerScope1.GetInstance<ParentTask>();

            var outerScope2 = AsyncScopedLifestyle.BeginScope(container);
            var parent2 = outerScope2.GetInstance<ParentTask>();

            await parent1.Run(container);
            await parent2.Run(container);

            // desired outcomes:

            // this stuff seems obvious. Different objects because different outer scopes.
            Debug.Assert(!ReferenceEquals(outerScope1, outerScope2));
            Debug.Assert(!ReferenceEquals(parent1, parent2)); 

            // the objects inside the parent tasks would be different.
            Debug.Assert(!ReferenceEquals(parent1.ScopedObject, parent2.ScopedObject));
            Debug.Assert(!ReferenceEquals(parent1.SharedObject, parent2.SharedObject));
            Debug.Assert(!ReferenceEquals(parent1.ChildTask, parent2.ChildTask));

            // the normally scoped object inside the child would be different because
            // the child was created on a different scope from the parent.
            // and the scoped object has the regular scoped lifestyle.
            Debug.Assert(!ReferenceEquals(parent1.ScopedObject, parent1.ChildTask.ScopedObject));
            Debug.Assert(!ReferenceEquals(parent2.ScopedObject, parent2.ChildTask.ScopedObject)); 

            // here is where it gets ugly.
            // I want the SharedObject to be different between the two parents,
            // but the same between parent and child.
            Debug.Assert(ReferenceEquals(parent1.SharedObject, parent1.ChildTask.SharedObject));
            Debug.Assert(ReferenceEquals(parent2.SharedObject, parent2.ChildTask.SharedObject));
        }
    }
}
dotnetjunkie commented 8 months ago

Theoretically, it would be possible to create a custom lifestyle, but there are many caveats in doing that. This is something I, therefore, don't advice.

This 'scope skipping' is something that is not built-in to Simple Injector and I think most DI Containers will give you troubles implementing this.

But there are always ways around this. For instance, by adding a wrapper class for SharedObject. For instance:

// Wrapper
class SharedObjectContainer
{
    public SharedObject SharedObject { get; set; }
}

// Registration
container.Register(
    typeof(SharedObjectContainer),
    typeof(SharedObjectContainer),
    Lifestyle.Scoped);

var producer = Lifestyle.Transient.CreateProducer<SharedObject>(
    typeof(SharedObject), container);

container.Register<SharedObject>(
    () => container.GetInstance<SharedObjectContainer>().SharedObject
        ?? producer.GetInstance(), Lifestyle.Scoped);

// Inside ParentTask
public async Task Run(Container container)
{
    var innerScope = AsyncScopedLifestyle.BeginScope(container);

    // IMPORTANT: Set SharedObject from ParentTask
    innerScope.GetInstance<SharedObjectContainer>().SharedObject = this.SharedObject;

    this.ChildTask = innerScope.GetInstance<ChildTask>();

    await this.ChildTask.Run();
}

I hope this helps

wleader commented 8 months ago

I'll give things a try. I actually had tried the whole wrapper thing I couldn't figure out how to get the container to verify the registrations when the wrapper had not been initialized yet. I think the magic in your suggestion over what I was trying is the part where you create a transient producer to create one if the wrapper doesn't already have one set.

Also, How very awesome how quickly you came back with advice. 👍

wleader commented 8 months ago

I did have to make a small change to the suggestion, the producer needed to be scoped.

var producer = Lifestyle.Scoped.CreateProducer<SharedObject>(
    typeof(SharedObject), container);

Unfortunately this introduces a new problem. When the inner scope ends, the SharedObject is getting disposed. It does work if I add a flag to the shared object that causes it to ignore the dispose, and then clear the flag after the scope is disposed, but that feels ugly.

I'm open to further suggestion, but if there isn't anything else to be done then this question can be closed.

Thank you for your help.

wleader commented 8 months ago

Just as a follow up in case anyone else comes across this post, and runs into a similar situation:

Another side effect of this strategy has been that inside my child scope I am using Entity Framework Core , and the Entity Framework Core DBContext is an IDisposable. The object that I am sharing between contexts is the DB connection and Transaction. So this ends up meaning that when the inner scope ends, it tries to dispose the DBContext, and the Entity Framework DBContext assumes that it owns the database connection and transaction and tries to dispose them as well. The work around here is to Extend DbContext and intercept the Dispose and prevent it from running too soon.

It feels a bit broken to me that an object should know something about the container scope, and change its behavior around when the scope ends. I'm not sure what can be done about it.