Skip to content

5. Dependency injection

Joni Savolainen edited this page Jul 8, 2023 · 11 revisions

Summary

Dependency injection requires an interface. Below is a simple example:

public interface ICounterService
{
    void IncrementCount();
    int GetCount();
}

public class CounterService : ICounterService
{
    private int _count;
    
    public void IncrementCount() => _count++;
    public int GetCount() => _count;
}

The [Dependency] attribute is used to specify a dependency and its default implementation. Then, UIComponent's protected Provide<T> method is used to inject it.

using UIComponents;

// First, define the interface we have a dependency to.
// Second, define the concrete implementation of that
// interface we want. Since source generation is used,
// our class must be declared as partial.
[Dependency(typeof(ICounterService), provide: typeof(CounterService))]
public partial class CounterComponent : UIComponent
{
    private readonly ICounterService _counterService;   
    private readonly Label _countLabel;

    public CounterComponent()
    {
        // Provide<T> gives you an instance of the dependency, which
        // in this case is CounterService, unless it is overridden
        // i.e. in a test
        _counterService = Provide<ICounterService>();
    
        _countLabel = new Label(_counterService.GetCount().ToString());
        Add(_countLabel);
    
        var incrementButton = new Button(IncrementCount);
        incrementButton.text = "Increment";
        Add(incrementButton);
    }

    private void IncrementCount()
    {
        _counterService.IncrementCount();
        _countLabel.text = _counterService.GetCount().ToString();
    }
}

This creates a component which can be used to increment a number.

Dependencies act as singletons by default. This means that every UIComponent will receive the same instance of the dependency.

See below how CounterComponent's count remains unchanged after its editor window is closed. This is because the count itself is stored in the singleton CounterService.

CounterComponent in action

ProvideAttribute

[Provide] will generate the Provide<T> calls for you and do them in the inherited constructor.

using UIComponents;

[Dependency(typeof(ISettingsService), provide: typeof(SettingsService))]
[Dependency(typeof(IDataService), provide: typeof(DataService))]
public partial class ComponentWithDependencies : UIComponent, IOnAttachToPanel
{
    [Provide]
    private ISettingsService SettingsService;

    // You can tell the attribute to cast from your interface
    // and use a concrete type in your field.
    // This can be useful when using a version of C# which does not
    // support all of the shiny new features related to interfaces.
    // Note that this will make testing difficult, so use it as
    // a last resort.
    [Provide(CastFrom = typeof(IDataService))]
    private DataService DataService;
    
    public void OnAttachToPanel(AttachToPanelEvent evt)
    {
        SettingsService.LoadSettings();
        DataService.DoSomethingWithData();
    }
}

Transient dependencies

Each consumer of a transient dependency receives its own instance.

To mark a dependency as transient, pass Scope.Transient as the third argument to [Dependency].

using UIComponents;

[Dependency(typeof(ICounterService), provide: typeof(CounterService), Scope.Transient)]
public partial class CounterComponent : UIComponent
{
    // snip
}

Transient dependencies are ideal when they contain data that should be destroyed alongside the consumer, e.g. a handle to some resource.

Get dependency safely

Provide<T> will throw a MissingProviderException if no providers exist for the dependency. If you're unsure whether a provider exists, use TryProvide<T>:

if (TryProvide<ICounterService>(out var counterService))
    counterService.IncrementCount();

Inheritance

UIComponents inherit dependencies. Such dependencies can be overridden by specifying a different provider for them.

[Dependency(typeof(IStringDependency), provide: typeof(StringDependency))]
[Dependency(typeof(IScriptableObjectDependency), provide: typeof(HeroProvider))]
public partial class MyComponent : UIComponent {}

[Dependency(typeof(IScriptableObjectDependency), provide: typeof(VillainProvider))]
public partial class OtherComponent : MyComponent {}

Assembly-wide dependency declarations

[Dependency] can be applied to an assembly. As a result, all UIComponents declared in the same assembly will have the dependencies configured. Individual classes can override the assembly-level dependency.

[assembly: Dependency(typeof(IMyDependency), provide: typeof(MyDependency))]

// Will receive MyDependency if IMyDependency is requested.
public partial class FirstComponent : UIComponent {}

// Will receive SecondDependency if IMyDependency is requested.
[Dependency(typeof(IMyDependency), provide: typeof(SecondDependency))]
public partial class SecondComponent : UIComponent {}

// Will receive own instance of MyDependency, instead of the shared one.
[Dependency(typeof(IMyDependency), provide: typeof(MyDependency), Scope.Transient)]
public partial class ThirdComponent : UIComponent {}

Testing

TestBed<T> is used to test UIComponents or other classes with dependency injection. After configured with the desired dependencies, its Instantiate method should be used create instances.

using NUnit.Framework;
using UIComponents.Testing;

[TestFixture]
public class MyTests
{
    private TestBed<CounterComponent> _testBed;
    private ICounterService _counterService;

    [SetUp]
    public void SetUp()
    {
        // You may want to use a mocking library like NSubstitute.
        _counterService = new MockCounterService();
        // A singleton is set using WithSingleton<T> during configuration.
        _testBed = new TestBed<CounterComponent>()
            .WithSingleton<ICounterService>(_counterService);
    }

    [UnityTest]
    public IEnumerator It_Works()
    {
        var counterComponent = _testBed.Instantiate();
        counterComponent.Initialize();
        // Since component initialization is asynchronous,
        // we must wait for it to finish.
        yield return counterComponent.WaitForInitializationEnumerator();
        // Do your assertions...
    }
}

When CounterComponent asks for ICounterService in the test, it will receive the instance of MockCounterService created in the SetUp function.

Clone this wiki locally