EDIT: The ExampleMonoBehaviour above is a better example. I will leave this for historical purposes. I’m not sure why I was overcomplicating things, but the example injection code above is more straightforward.
I want to add an update to best practices and the ExampleMonoBehaviour sample code. At first, my examples just called InjectorLocator.GetInjector() from the Awake method, and threw an exception if a required depedency like ILogger was not injected. However, I think it’s actually better to inject from OnEnable, and not throw exceptions. I want to explain the logic behind that, and break down the particular way I’ve coded it in the new examples. The new example code has already been updated directly above this post.
Awake is only ever called once per MonoBehaviour, so if it fails, there’s no second chance. Switchboard is all about being able to inject dynamically, without having to stop play, or recompile. If you throw an exception from Awake, the component will be disabled, but then you can just enable it again via the editor or through scripts. Awake will not be called again, and it will be enabled and updating without the required dependencies.
When dropping a prefab into a scene, if required dependencies fail to inject, the MonoBehaviour should be disabled. This is similar to how a plain C# class fails to instantiate if it throws an exception from the constructor, like an ArgumentNullException when you pass null to a required argument. However, that exact same functionality cannot be achieved. The component is still created, so the next best thing is to disable it.
Throwing an exception from the Awake method automatically disables the component. (Note: Throwing an exception from OnEnable does not.) That works fine in the editor when dropping an object into a scene, but it’s not ideal when attempting to add a component to a game object through scripts. If Awake() can throw an exception, then AddComponent method calls would need to be wrapped in a try/catch block. I don’t think that’s a very good thing for me to expect of users.
If a dependency fails to inject from OnEnable, you can simply set enabled = false and return from the method. Then, Start() and Update() will not be called. You can check the enabled property of a MonoBehaviour to determine if it is ready to run. If you attempt to AddComponent, or enable a disabled MonoBehaviour, then it will attempt to enable and fail immediately if injection fails. Enabled will already be set false again, and you can check that to see if your attempt to enable the component succeeded or failed.
This also allows you to re-attempt injection each time the component is enabled. So, if you fix a problem at the composition root while the game is running, you can just enable the component and it will be properly injected, and start running as expected. Furthermore, this can help with testing. Even if injection was successful the first time, you can release your dependencies in OnDisable, and when the component is re-enabled it will get the latest instance from the injector. So, in the editor you can change the dependencies at the composition root, and toggle components to re-inject them directly in the editor without having to stop play.
So, let’s break down the new injection example line by line. First I create a local IInjector variable because we may need to call InjectorLocator.GetInjector(), or we may not. It’s possible this component is just disabled for some ordinary reason, and injection is working just fine. In that case, we may not need get get an IInjector reference. However, we may need to attempt injection again, and we can’t always be sure which dependency will need re-injection. So, it’s good to just start with this placeholder IInjector set to null in case we need it, and if we do we will only call InjectorLocator.GetInjector() once, and keep the local reference for any further injections.
IInjector injector = null;
The first dependency that I almost always inject is ILogger. A logger is one of the most fundamental dependencies of any software application. Without a logger we can’t create a record of what’s going on. So, it’s a natural fit that we would attempt to inject it first. This gives us an example of a required dependency. If the logger is not injected, the first thing I do is set enabled = false. This is to guarantee that the component will be disable due to this, even in the very unlikely event that the next line of code throws an exception (I know for a fact it is possible, however unlikely). Then, I log the error using Debug.Log. This is an inferior logging method, but it’s better than nothing, and I don’t want to throw an exception. Then I just return early from OnEnable.
Logger ??= (injector ??= InjectorLocator.GetInjector())?.Get<ILogger>();
if(Logger == null)
{
enabled = false;
Debug.LogError("No Logger!");
return;
}
Now I want to explain the line of code that does the injection. I suspect it might appear strange to some users. The good news is that you don’t need to know anything about how this line of code works to use it. It’s just a bunch of syntactic sugar to put this common operation on a single line. You can learn more about null-coalescing operators here: ?? and ??= operators - null-coalescing operators - C# reference | Microsoft Learn You never want to use these operators on UnityEngine.Object derived types because of the way the overloaded equality operator works, but it’s totally fine to use them here because we are working with interfaces. So, if we were to unpack this:
Logger ??= (injector ??= InjectorLocator.GetInjector())?.Get<ILogger>();
It would be the same as writing this:
if(Logger == null)
{
if(injector == null)
injector = InjectorLocator.GetInjector();
if(injector != null)
Logger = injector.Get<ILogger>();
}
That allows us to check if a dependency is null, check if we already have an injector, get the injector if we don’t already have it, and attempt to inject the dependency, before we then check the dependency for null again to see if any part of that process failed. (Technically the first time we do this, we don’t need to check if injector is null because we know it is. But, what if we re-arranged the order of the code? Writing it the same way every time does no harm, and it prevents us from making any copy/paste errors.) It’s just taking that common operation that can be performed universally as you go down the list of dependencies, and putting it all on one line of code. You don’t have to fully understand it, you can just copy and paste it, and edit the member being injected at the beginning, and the type being requested at the end.
Now that we have the ILogger, when we attempt to inject this optional dependency, we can just log a warning and move on. That’s why I inject the ILogger first. Once we have it, we can use it to properly log errors or warnings as we continue the injection process. This part is supposed to represent an optional dependency that this component can work with, but is not strictly required for the component to enable and update.
OptionalDependency ??= (injector ??= InjectorLocator.GetInjector())?.Get<IOptional>();
if(OptionalDependency == null)
Logger.LogWarning("Optional dependency unavailable.");
Then, from OnDisable, I release the optional dependency. I don’t release the ILogger reference because I think it’s a totally reasonable expectation for the ILogger to not change over the course of the application, or at the very least to be allowed to use the first one I was given. Other dependencies may not work the exact same way and you may not have the same expectations. It may make more sense to release references to a type of dependency that you were given. This does two things: If that dependency has been pruned from the composition root, releasing your references will allow it to be garbage collected. Also, it allows the code we’ve written, that wonky line of syntactic sugar, to re-inject a dependency if it is null. Now, you could hold onto the reference even while disabled, and re-inject on every OnEnable, without checking if the one you kept is null. But, that would change the way the wonky example injection line is written, and I don’t see why we would need to hold the reference while disabled. So, just releasing references that you don’t want to maintain from the first injection in OnDisable allows them to be re-injected, and you can copy and paste that one line of code for injection without having to think too much about it.
private void OnDisable()
{
OptionalDependency = null;
}
And, that’s it! Injecting this way is much more robust and flexible. It works if you’re dropping an object into a scene, adding the MonoBehaviour to a game object through script, instantiating a prefab, loading a level, whatever! If a required dependency is not injected, the component will just disable. If you are working with scripts, any attempt to create or enable the component will immediately call OnEnable(), and if it fails, enabled will be set to false immediately. So, you can easily have conditional code that knows whether that component was injected properly or not. Also, it will log warnings and errors itself for you to see. Also, if injection fails for any reason, or if you change something at the composition root, you can just toggle the enabled status and it will properly re-inject itself, even live without having to stop playing.
If you are concerned that you may fail to provide some required dependencies in the DependencyInjector you have assigned to the composition root, you can simply create a unit test with a comprehensive list of all the types of dependencies you expect to be required by your app. Just run the DependencyInjector in question through the test. Easy! This can be as simple as a MonoBehaviour in an empty scene that just tries to inject itself with every type of dependency you have in your game.
I hope that makes sense to anyone who may be working with Switchboard. If you have any questions just let me know.