Skip to content

DIScope - an implementation of dependency injection container pattern #461

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from

Conversation

pdeschain
Copy link
Contributor

@pdeschain pdeschain commented Feb 3, 2022

Description

This PR adds an implementation of a dependency injection container pattern (DIScope).
DIScope is only capable, for the sake of simplicity, of binding and resolving singletons.

The gist of DI is as follows: single responsibility principle, when applied to object instantiation, would suggest that if an object relies on the presence of certain dependencies, then finding those dependencies is outside the scope of instantiation itself. This gives us Dependency Injection Container - a class (With a special singleton reference to a Root scope) that will be responsible for providing dependencies, thus achieving a notion of Inversion of Control.

It effectively eliminates the need for singletons, allows to have more decoupled codebase that can be largely reliant on interface-based dependencies. DI also allows for easier testing of systems - we can easily swap out one implementation of an interface for another implementation and the only place where we'd need to touch the code is in the place where we declare dependencies and bind them to the DIScope.

So now we have this thing that is meant to provide dependencies, how do we author our code?

The code below showcases both the Binding stage (where we declare our dependencies which themselves can be interdependent on each-other) and an example of dependency injection and manual resolving:

/// <summary>
    /// Constructor injection
    /// That's how we would author regular C# classes that have dependencies that will be resolved with DI
    /// </summary>
    public class B
    {
        private readonly ITestInterface _a;

        [Inject]
        public B(ITestInterface a)
        {
            _a = a;
        }
    }
    
    /// <summary>
    /// Method injection for regular classes can be used to have a non-injected default constructor coupled with an [Inject] method,
    /// which allows to delay the moment of dependency resolution after the instantiation. Can be useful in certain cases.
    /// </summary>
    public class MethodInjectedClass
    {
        private ITestInterface _a;

        [Inject]
        private void InjectDependencies(ITestInterface a)
        {
            _a = a;
        }

        public MethodInjectedClass()
        {
            //non-injected constructor that will be invoked prior to the InjectDependencies method
        }
    }

    /// <summary>
    /// MonoBehaviour-derived classes can only have method injection
    /// </summary>
    public class MonobehWithDependencyOnOther : MonoBehaviour
    {
        private A _a;
        private MonobehDependency _mb;

        [Inject]
        private void InjectDependencies(A a, MonobehDependency mb)
        {
            _a = a;
            _mb = mb;
        }
    }
    
    
    public interface ITestInterface
    {

    }

    /// <summary>
    /// This class will be instantiated, but not injected by the DIScope, as it has no [Inject]-decorated constructor or method
    /// </summary>
    public class A : ITestInterface
    {

    }


    public class MonobehDependency : MonoBehaviour
    {
        //non-injected external dependency that we'd like to pipe using DIScope
        //it's implementation is not important at all.

        //IMPORTANT - DIScope can't create MonoBehaviours, so these should be created and provided to the DIScope manually.
        //also the lifetime of these objects is not tied to the DIScope - it only cleans up Disposables that it created.
    }

And this is how we would provide the dependencies to the di scope:

    public class DIDemo_Bootstrap : MonoBehaviour
    {

        [SerializeField] private MonobehDependency _monoBehDependency;
        
        private void Awake()
        {
            DontDestroyOnLoad(gameObject);

            //DIScope can be made a child of another DIScope - this way the dependencies will be looked up recursively from child to parent scope.
            //this would allow one to dispose of the child scope independently, cleaning up scene-specific references or something like that
            
            var scope = DIScope.RootScope;

            //lazy-binding, as in, DIScope will create instance of A when it's first asked to resolve an ITestInterface
            scope.BindAsSingle<A, ITestInterface>();
            //B is bound lazily, but it will be only resolvable through it's direct type - B
            scope.BindAsSingle<B>();

            //monobehaviours can't be created by DIScope, so we create it elsewhere and then bind it,
            //keeping in mind that killing that object is our responsibility, not DIScope's
            scope.BindInstanceAsSingle(_monoBehDependency);
            //and now we want to make the DIScope go through all of it's bound objects (excluding lazy-bound types) and injecting them
            //this process can to a certain extent solve the problem when there are several objects that need to have a reference to each other - it's resolved by method injection
            scope.FinalizeScopeConstruction();

            //after this it's safe to ask the scope to give us any objects (or to inject dependencies into objects)
            var a = scope.Resolve<ITestInterface>();
            
            var mbehWithDep = gameObject.AddComponent<MonobehWithDependencyOnOther>();
            scope.InjectIn(mbehWithDep);
            //and now mbehWithDep is fully functinal and has all of it's dependencies passed to it.
            //injecting a gameObject would inject all the components on all the children.
        }

        private void OnDestroy()
        {
            //cleanin up the root scope
            DIScope.RootScope.Dispose();
        }
    }

Related Pull Requests

Issue Number(s) (*)

Fixes issue(s):

Manual testing scenarios

  1. ...
  2. ...

Questions or comments

Contribution checklist

  • Pull request has a meaningful description of its purpose
  • All commits are accompanied by meaningful commit messages
  • All new or changed code is covered with unit/integration tests (if applicable)
  • All automated tests passed successfully (all builds are green)

@pdeschain pdeschain added the 1-Needs Review PR needs attention from the assignee and reviewers label Feb 3, 2022
@@ -0,0 +1,333 @@
using System;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move those to Utilities? They seem pretty boss room indepandant and would be great to reuse in future samples

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely.

m_TypesToInstances[type] = instance;
}

public void BindAsSingle<TImplementation, TInterface>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason this has the same name as BindAsSingle with only one type?
Basically, this is a way to bind a default implementation for a given interface correct? Just "BindAsSingle" might be a bit confusing for that.
On another hand we don't want BindDefaultImplementationToInterfaceAsSingle...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there is no specific reason to go with an overloaded function, however I just have a preference for overloads when they seem to make sense. So the intent here is to allow the user to bind a concrete implementation type that will be available by it's concrete type (that's a default of the DIScope - if something is bound, it is always automatically bound to (at the very least) it's concrete type) AND by the interface type.

I'll try and see if there are some better groupings and names for these method - lmk if you have ideas for short and concise intentful namings.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BindInterfaceImplementationAsSingle seems like a mouthful too.
BindInterfaceAsSingle?

foreach (var component in components) InjectIn(component);
}

~DIScope()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

general formatting, usually you like having your destructor close to your constructor :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah crap, that's some automatic code cleanup that I did - that horrible foreach is also caused by the same... Or is it our actual codestyle? Anyhow - you're right - all of the creation/destruction logic belongs together.

@pdeschain pdeschain force-pushed the pdeschain/dependency-injection-container branch from e39a997 to 9520990 Compare February 14, 2022 16:03
@pdeschain pdeschain mentioned this pull request Feb 14, 2022
4 tasks
@pdeschain
Copy link
Contributor Author

closing this PR - the content in here was merged with Lobby PR

@pdeschain pdeschain closed this Feb 24, 2022
@pdeschain pdeschain deleted the pdeschain/dependency-injection-container branch March 23, 2022 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
1-Needs Review PR needs attention from the assignee and reviewers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants