loading...
Cover image for AvaloniaUI: Dependency Injection

AvaloniaUI: Dependency Injection

ingvarx profile image Ingvar Updated on ・5 min read

Hello, in this post I gonna explain how to use dependency injection in AvaloniaUI application and why is it so important. As always I will use my app Camelot as working example of used approach.

What is dependency injection (DI) and why is it needed

Consider following example:

private readonly TerminalService _terminalService;
private readonly DirectoryService _directoryService;

public TopOperationsViewModel()
{
    _terminalService = new TerminalService();
    _directoryService = new DirectoryService();
}

What's wrong with this code? It's a constructor of typical view model that depends on few services with business logic inside. In this snippet I created them in a constructor explicitly. That's bad approach because of following reasons:
1) What if we want to use another implementation of TerminalService for example that has same API but another implementation (lets call it AnotherTerminalService)? We will have to loop over all our files and change usages of TerminalService to AnotherTerminalService! A lot of work
2) What if we have to use services that depends on other services and so on? In this case in each view model we will have something like this mess:

public TopOperationsViewModel()
{
    _terminalService = new TerminalService(new PathService(), new DriveService());
    _directoryService = new DirectoryService();
}

And if we have some runtime dependencies it's even worse:

public TopOperationsViewModel()
{
    _terminalService = new TerminalService(new PathService(), RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? new LinuxSpecificService() : new WindowsSpecificService());
    _directoryService = new DirectoryService();
}

Absolutely unreadable code 😃

3) What if used service has some methods/properties that shouldn't be available in our view model? We can do something like this:

private readonly ITerminalService _terminalService; // note interface here

public TopOperationsViewModel()
{
    _terminalService = new TerminalService(); // saving by interface
    _directoryService = new DirectoryService();
}

In this case nobody could prevent programmer from modifying this code to use implementation directly based on knowledge what type is used.

Dependency injection provides flexible approach that solves all mentioned problems. In this approach you INJECT your dependencies into constructor of your type. Usually additional framework is used to help with this. Your code starts to look like this:

private readonly ITerminalService _terminalService;
private readonly IDirectoryService _directoryService;

public TopOperationsViewModel(
    ITerminalService terminalService,
    IDirectoryService directoryService) // note that interface is used
{
    _terminalService = terminalService;
    _directoryService = directoryService;
}

In example above dependencies are passed by interface as constructor parameters. DI framework takes responsibility for passing these parameters in correct order. Interfaces hide details of implementation and allow you to change injected classes w/o modifying dependent code. Your view model starts to depend on abstractions instead of implementations.

Using DI in Avalonia app

As I mentioned before under hood Avalonia uses ReactiveUI framework. It has support for DI using library called Splat. I will show how to use it in your project.

ReactiveUI Splat doc

Adding Splat

Splat is a dependency of Avalonia.ReactiveUI, but you can install it into your root project explicitly using Nuget package manager.

After installing you can access Splat.Locator instance and use it for registering/resolving your services.

Service locator

Internally Splat uses service locator pattern for resolving dependencies. Service locator is actually anti-pattern. It provides static accessible everywhere services resolver (locator). Splat.Locator.Current is an example of such locator. It could be used like this:

public override void OnFrameworkInitializationCompleted()
{
    if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
    {
        desktop.MainWindow = new MainWindow
        {
            DataContext = Locator.Current.GetRequiredService<MainWindowViewModel>()
        };
    }

    base.OnFrameworkInitializationCompleted();
}

Why is it anti-pattern? It used as a way to get rid of dependencies in your code but instead of that it adds one dependency - service locator itself! I recommend to avoid using this pattern if possible. Sometimes it could be useful, but you don't need in most cases.

Registering dependencies

I moved dependencies registration into separate static class Bootstrapper which is called from program entry point:

Bootstrapper.Register(Locator.CurrentMutable, Locator.Current); // note that I'm passing Splat service locators as parameters

Locator.CurrentMutable allows you to register new services while Locator.Current allows to resolve registered services.

Bootstrapper itself looks like this:

public static class Bootstrapper
{
    public static void Register(IMutableDependencyResolver services, IReadonlyDependencyResolver resolver)
    {
        services.Register<IPlatformService>(() => new PlatformService());  // Call services.Register<T> and pass it lambda that creates instance of your service
    }
}

What if you want to create service with constructor parameters? You can achieve this using following code:

services.RegisterLazySingleton<IUnitOfWorkFactory>(() => new LiteDbUnitOfWorkFactory(
    resolver.GetService<DatabaseConfiguration>()
));

Look how IReadonlyDependencyResolver instance is used for creating service instance:

resolver.GetService<DatabaseConfiguration>() // call resolver.GetService<T>() to get instance of type T

IMutableDependencyResolver has 3 methods for registering your service:
1) RegisterConstant allows you to inject some constant value of specific type. I use it for injecting configuration:

var filePanelConfiguration = new FilePanelConfiguration();
            configuration.GetSection("FilePanel").Bind(filePanelConfiguration);
            services.RegisterConstant(filePanelConfiguration);

2) Register. Service registered with this name will be created each time requested:

services.Register(() => new GeneralSettingsViewModel(
                resolver.GetRequiredService<LanguageSettingsViewModel>()
            ));

3) RegisterLazySingleton. Once created, service instance will be reused in future so it acts like a singleton.

services.RegisterLazySingleton<IBitmapFactory>(() => new BitmapFactory());

Registering dependencies properly

What if Splat doesn't find implementation for your service? Modern frameworks mostly throw exception in this case but Splat injects null instead. Why is it not good? You want to see error on startup but you will have runtime error (NRE somewhere in your code) that much harder to detect. How to avoid this? I recommend to use extension methods that will throw an error if resolved type instance is null:

public static TService GetRequiredService<TService>(this IReadonlyDependencyResolver resolver)
{
    var service = resolver.GetService<TService>();
    if (service is null) // Splat is not able to resolve type for us
    {
        throw new InvalidOperationException($"Failed to resolve object of type {typeof(TService)}"); // throw error with detailed description
    }

    return service; // return instance if not null
}

Usage of this method is similar:

services.Register<ITopOperationsViewModel>(() => new TopOperationsViewModel(
    resolver.GetRequiredService<ITerminalService>(),
    resolver.GetRequiredService<IDirectoryService>(),
    resolver.GetRequiredService<IFilesOperationsMediator>()
));

Example of code

With this approach you won't have unexpected NullReferenceException in your code.

Examples of using DI

Usage in view model:

public TopOperationsViewModel(
    ITerminalService terminalService,
    IDirectoryService directoryService,
    IFilesOperationsMediator filesOperationsMediator)
{
    _terminalService = terminalService;
    _directoryService = directoryService;
    _filesOperationsMediator = filesOperationsMediator;

    SearchCommand = ReactiveCommand.Create(Search);
    OpenTerminalCommand = ReactiveCommand.Create(OpenTerminal);
}

Registration of this type:

services.Register<ITopOperationsViewModel>(() => new TopOperationsViewModel(
    resolver.GetRequiredService<ITerminalService>(),
    resolver.GetRequiredService<IDirectoryService>(),
    resolver.GetRequiredService<IFilesOperationsMediator>()
));

All registrations code is available here

DI testing

Developers often forget to register implementations of used interfaces. How to automate check that everything is registered properly? For this purpose solution is to write tests. You can do following:
1) Call registrations code and try to resolve specific service/view model
2) Call registrations code and try to create instances of all registered types.

In my project I used combined approach. I checked that all registrations are correct (using wrapper for IMutableDependencyResolver) but also I have dynamically created from code instances of dialogs so I checked them separately. Full tests code is available here.

Conclusion

Hope that now you know how to use DI in your Avalonia app and why is it needed. Feel free to contact me via comments if you have any questions. Thanks for reading!

Discussion

pic
Editor guide