I’ve recently been experimenting with Avalonia UI, a development framework for desktop and mobile platforms, and I’ve been enjoying the experience. While Avalonia has much to offer out of the box, it is more than happy to let you make many decisions. One of those decisions is whether to use dependency injection as a part of your application.

In this experimental post, we’ll see how I added dependency injection into an ongoing Avalonia application and discuss the pros and cons of the approach.

Let’s get started.

Registering Your Dependencies

Since no infrastructure exists for user-defined dependency injection in Avalonia (or at least non that I am aware of), we must create our own. The first step to any dependency injection approach is finding and registering all our dependencies.

For the post, I’m using Lamar, the spiritual successor of StructureMap. Of course, you can substitute your own, but I like Lamar’s interface for scanning and registering types.

I added the following code in Program.cs of my Avalonia app.

public static IContainer Container { get; }

static Program()
{
    Container = new Container(cfg =>
    {
        cfg.Scan(scan =>
        {
            scan.AssemblyContainingType<Program>();
            scan.AddAllTypesOf<ViewModelBase>();
            scan.AddAllTypesOf<IControl>();
            scan.WithDefaultConventions();
        });
    });
}

This code scans my application for all types of ViewModelBase and IControl along with the default conventions of Lamar. A sane default is registering concrete types against their first interface. Now that we set up our dependency graph let’s start changing our view models and views.

WindowBase and ViewModelBase

Using the Model-View-ViewModel approach, I must create base classes for my views and viewmodels. Let’s look at both now, starting with my WindowBase class.

using Avalonia.Controls;
using HelloAvalonia.ViewModels;
using Lamar;

namespace HelloAvalonia.Views;

public abstract class WindowBase<T> : Window
    where T: ViewModelBase
{
    [SetterProperty]
    public T ViewModel
    {
        get => (T)DataContext!;
        set => DataContext = value;
    }
}

You’ll notice I’m using an attribute called SetterProperty on a new ViewModel property. As Lamar builds our Window, it will also resolve the ViewModel and set the DataContext. I opted to use property injection to allow the Avalonia preview tool to continue working in JetBrains Rider.

Now, let’s look at our ViewModelBase implementation.

using CommunityToolkit.Mvvm.ComponentModel;

namespace HelloAvalonia.ViewModels;

public abstract class ViewModelBase : ObservableObject
{
}

I’m using the CommunityToolkit.Mvvm package, so my ViewModels inherit from ObservableObject. Now, let’s implement a view model instance.

MainWindow, MainWindowViewModel, and Dependencies

Like our ViewModel property in our WindowBase class, we’ll resolve all dependencies using the SetterProperty attribute. First, let’s change our main view to inherit from WindowBase. The change will ensure our DataContext is set as our container creates the instance.

using System;
using HelloAvalonia.ViewModels;
using Lamar;

namespace HelloAvalonia.Views;

public partial class MainWindow : WindowBase<MainWindowViewModel>
{
    public MainWindow()
    {
        InitializeComponent();
    }
        
    [SetterProperty]
    public DialogWindow? Dialog { get; set; }

    public Action ShowDialogInteraction => 
        () => Dialog?.ShowDialog(this);
}

I will also need another dialog window to show some other information. For example, I can ask for any control registered in my container.

In this example, I’m using the dialog instance to create an interaction and pass it to my view model to keep controls out of my business logic.

<Button
    Content="Show Dialog"
    Command="{Binding ShowDialogCommand}"
    CommandParameter="{Binding #Main.ShowDialogInteraction}"
    HorizontalAlignment="Center">

Now let’s see the MainWindowViewModel implementation.

using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace HelloAvalonia.ViewModels;

public partial class MainWindowViewModel : ViewModelBase
{
    [ObservableProperty, NotifyPropertyChangedFor(nameof(PlayAnimation))]
    private int _count;

    [ObservableProperty]
    private bool _isEnabled = true;

    [ObservableProperty] private string _text = "Click Me";
    public bool PlayAnimation => Count > 0 && Count % 2 == 0;

    [RelayCommand]
    private void Click()
    {
        Count++;
        Text = Count == 1
            ? $"Clicked {Count} time"
            : $"Clicked {Count} times";
    } 

    [RelayCommand]
    private void ShowDialog(Action? showDialogInteraction)
    {
        showDialogInteraction?.Invoke();
    }
}

There are no dependencies here, but we pass our interaction to our ShowDialog method. So what’s the dialog implementation look like?

using System;
using HelloAvalonia.ViewModels;

namespace HelloAvalonia.Views;

public partial class DialogWindow : WindowBase<DialogWindowViewModel>
{
    public DialogWindow()
    {
        InitializeComponent();
    }
    
    public Action HideInteraction => Hide;
}

And the View Model is even simpler.

using System;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HelloAvalonia.Models;
using Lamar;

namespace HelloAvalonia.ViewModels;

public partial class DialogWindowViewModel : ViewModelBase
{
    [SetterProperty] 
    public ICatsImageService? Cats { get; set; }

    [ObservableProperty] private Bitmap? _catImage;

    [RelayCommand]
    private async Task Opened()
    {
        if (Cats is { })
        {
            var bitmap = await Cats.GetRandomImage();
            CatImage = bitmap;
        }
    }

    [RelayCommand]
    private void Hide(Action? interaction)
    {
        CatImage = null;
        interaction?.Invoke();
    }
}

We are injecting an instance of ICatsImageService using Lamar’s SetterProperty attribute.

So now that we have our views and viewmodels, where is our entry point?

The Entrypoint

The entry point of an Avalonia app occurs in the App implementation. Therefore, I modify the OnFrameworkInitializationCompleted method to use the container I define in Program.

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using HelloAvalonia.Views;

namespace HelloAvalonia;

public partial class App : Application
{
    public override void Initialize()
    {
        AvaloniaXamlLoader.Load(this);
    }

    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            desktop.MainWindow = 
                Program
                .Container
                .GetInstance<MainWindow>();
        }

        base.OnFrameworkInitializationCompleted();
    }
}

That’s it! We now have a working application.

Conclusion

Some folks hate dependency injection, and some folks love it. I think it has value in complex applications where you want to define “how” things are set up in one place and forget about it. With Avalonia, it takes a bit of work to get started with dependency injection, but not too much.

It’s essential to be mindful of the lifetimes of your controls and their dependencies. As you’re dealing with a desktop application, I don’t see a need to create multiple instances of objects, so Singleton registrations should work fine in most cases.

Well, I hope you liked this post. I’ve included the code to this Avalonia UI sample on my GitHub repository for you to try.