Are you using Dependency Injection in your .NET MAUI app yet?

Are you using Dependency Injection in your .NET MAUI app yet?

Learn how to leverage the powerful, built-in dependency injection capabilities of .NET MAUI and Shell

ยท

12 min read

Introduction

In this article, I will show you how to leverage MAUI's built-in dependency injection capabilities to provide dependencies to your Views and ViewModels.

I absolutely love the Dependency Inversion Principle (DIP), it is by far my favorite principle from SOLID, and .NET MAUI makes it very easy to apply Inversion of Control (IoC) with it, because as a first class-citizen, Dependency Injection (DI) already comes built-in with Shell apps. Like ASP.NET Core, .NET MAUI also uses .NET's Dependency Injection pattern.

SOLID stands for:

  • SRP = Single Responsibility Principle

  • OCP = Open/Closed Principle

  • LSP = Liskov Substitution Principle

  • ISP = Interface Segregation Principle

  • DIP = Dependency Inversion Principle

Uff, a lot of fancy acronyms and buzz words. While DIP, IoC and DI are not all exactly the same, they express and address different aspects of the same idea: Loose coupling. Instead of having hard dependencies in our code, which make it unflexible and difficult to maintain and test, the idea is to invert the control of the dependencies by hiding implementation details and only depending on interfaces or contracts instead.

Specific implementations of those dependencies are then injected. This can be done via constructor arguments, IoC containers, the builder pattern or specific setter methods. In this blog post, we will explore how to leverage Shell's built-in dependency injection, which uses a combination of the first three approaches.

This is entirely optional, of course. If you want, you can also use any other IoC container, there are plenty of great DI frameworks and IoC containers out there for .NET, like TinyIoC, SimpleInjector and Prism.

As always, you can find the code from this blog post in the sample repository on GitHub.

Note: This post focusses on dependency injection in .NET MAUI and is not a general discussion or tutorial about what DI is. If you want to learn more about DI patterns (and anti-patterns) and good software design in general, I highly recommend reading "Dependency Injection - Principles, Practices and Patterns" by Steven van Deursen and Mark Seemann.

Setting

For this example, we'll assume that we have a MainPage, a MainViewModel and an IDeviceService which is consumed by the MainViewModel. The MainViewModel somehow needs to be passed into the MainPage to serve as its BindingContext and the IDeviceService needs to be passed into the MainViewModel.

One of the most common ways to inject dependencies is via constructor injection, which we will mainly focus on today. Typically, this would look as follows:

public MainPage(MainViewModel viewModel)
{
    InitializeComponent();
    BindingContext = viewModel;
}

Here, we have defined the MainPage constructor which takes in a MainViewModel as a parameter, so our dependency gets injected via the constructor and can then be used in the class.

Similarly, the MainViewModel constructor takes in an instance of an IDeviceService implementation:

private readonly IDeviceService _deviceService;

public MainViewModel(IDeviceService deviceService)
{
    _deviceService = deviceService;
}

Without the use of a DI service, we would have to manually set up all the constructor calls and somehow manage which dependency goes where. For example, we would have to do the following in order to instantiate the MainPage in the App.xaml.cs:

public App()
{
    InitializeComponent();
    var deviceService = DeviceService.Instance;
    var viewModel = new MainViewModel(deviceService);
    MainPage = new MainPage(viewModel);
}

This works perfectly fine as long as the app is small and doesn't use Shell; but, what if we have many different pages and want to use Shell to build our app hierarchy using routes and <ShellContent> objects?

One thing we definitely couldn't do is the following, because in XAML you cannot provide constructor parameters to a DataTemplate:

<Shell
  x:Class="MauiSamples.AppShell"
  xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  xmlns:views="clr-namespace:MauiSamples.Views">

  <TabBar>
    <Tab Title="Home">
      <ShellContent
        Title="Home"
        ContentTemplate="{DataTemplate views:MainPage}"
        Route="MainPage" />
    </Tab>
</Shell>

The MainPage takes a MainViewModel instance as a constructor parameter, but we cannot provide that in XAML.

This is where MAUI's built-in dependency injection comes in. It uses its own IoC container and performs the resolution of the dependencies for us automatically, provided that we register all the required dependencies.

So, let's have a look at how registration works.

Registration

In MAUI, we use the builder pattern, which is a common approach for app configuration in modern .NET applications and technologies. It makes the configuration of features and dependencies very straightforward and keeps it all in a single place.

The MauiProgram.cs is the place where the app is usually configured and built. There, you will find something like this:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
        })

    return builder.Build();
}

The builder object provides a Services collection where all dependencies get registered. This is how we let MAUI know that we have classes that have dependencies or that are dependencies for other classes.

Registering a new dependency is very easy and just takes a single line of code anywhere before the return builder.Build() call:

builder.Services.AddSingleton<MainViewModel>();

return builder.Build();

The code above is used to register the MainViewModel class. However, we also need to register the MainPage as well for the automatic resolution to work:

builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<MainPage>();

return builder.Build();

So, how does resolution work?

Resolution

There are two types of dependency resolution in MAUI apps: automatic and explicit.

Automatic resolution uses constructor injection without explicitly requesting the dependency from the IoC container, while explicit resolution occurs on demand by explicitly requesting a specific dependency from the IoC container.

Automatic Resolution with Shell

As we've already seen above, this type of resolution works - as the name suggests - completely automatically by providing the required dependencies to the constructor:

private readonly IDeviceService _deviceService;

public MainViewModel(IDeviceService deviceService)
{
    _deviceService = deviceService;
}

Shell takes care of this for you as long as you register the dependency's type as well as the type that uses this dependency, e.g. MainViewModel and an implementation of IDeviceService.

Note: Only Shell apps support automatic constructor injection.

This is pretty cool, because we do not have to pass around our dependencies ourselves, Shell takes care of it and manages the lifetime and handles the injection for us.

Explicit Resolution

If your class only exposes a parameterless constructor, then Shell cannot inject dependencies automatically for you. Or if you don't use Shell, because you want to use a FlyoutPage or implement navigation completely manually without using routes, then you also cannot use the automatic resolution mechanism. Therefore, we need an explicit way to resolve dependencies.

Note: I have included the following approaches to show that they are viable options. However, if you need to resolve dependencies manually in many different places or you have complex scenarios with deep dependency trees, you may want to use an independent DI framework or IoC container.

Accessing the IServiceProvider

Imagine our MainViewModel for some reason doesn't provide a constructor with the IDeviceService parameter:

private readonly IDeviceService _deviceService;

public MainViewModel() { }

In that case, we need to resolve the dependency manually by directly accessing the IoC container, e.g. in the constructor. One way to do this is the following, where we access the IServiceProvider (i.e. the Services object) via the MauiContext provided by our current MainPage:

private readonly IDeviceService _deviceService;

public MainViewModel()
{
    _deviceService = Application.Current.MainPage
        .Handler
        .MauiContext
        .Services  // IServiceProvider
        .GetService<IDeviceService>();
}

One downside of this approach is that we have a dependency on the Application and the MainPage in our MainViewModel, which kind of defeats the purpose of using IoC in .NET applications following the MVVM pattern. This also makes automatic testing tricky, because if we want write unit tests for our MainViewModel, we shouldn't have any dependencies on the Application or the MainPage.

Alternatively, we can also pass down the IServiceProvider via the constructor (either through automatic resolution or by manually injecting it in the constructor call) and then resolve the required dependencies manually:

private readonly IDeviceService _deviceService;
private readonly IAudioService _audioService;

public MainViewModel(IServiceProvider provider)
{
    _deviceService = provider.GetService<IDeviceService>();
    _audioService = provider.GetService<IAudioService>();
}

This slightly different approach allows us to keep the constructor signature short and allows us to use mocking frameworks with unit tests. This approach also works with Shell's automatic resolution without having to manually register the IServiceProvider.

๐Ÿ˜“ That'll work, but it's not pretty and we have to always pass down the IServiceProvider. Isn't there a better way? As you may have guessed: There is. ๐Ÿคฉ

ServiceHelper to the rescue!

If you don't want to or cannot use any constructor injection, then there is a better way to deal with the IServiceProvider, which is to create a static ServiceHelper class:

public static class ServiceHelper
{
    public static IServiceProvider Services { get; private set; }

    public static void Initialize(IServiceProvider serviceProvider) => 
        Services = serviceProvider;

    public static T GetService<T>() => Services.GetService<T>();
}

Then, in your MauiProgram.cs you just need to call the Initialize method and pass in the IServiceProvider instance that the MauiApp class exposes once it was built:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();

    // ...

    builder.Services.AddSingleton<IDeviceService>(DeviceService.Instance);
    builder.Services.AddSingleton<MainViewModel>();
    builder.Services.AddSingleton<MainPage>();

    var app = builder.Build();

    //we must initialize our service helper before using it
    ServiceHelper.Initialize(app.Services);

    return app;
}

Then, in order to resolve the dependencies, you can access the static helper and call the GetService<T>() method:

private readonly IDeviceService _deviceService;
private readonly IAudioService _audioService;

public MainViewModel()
{
    _deviceService = ServiceHelper.GetService<IDeviceService>();
    _audioService = ServiceHelper.GetService<IAudioService>();
}

The beauty of this approach is that we don't need to pass down the IServiceProvider in every single constructor call when we're not using constructor parameters and at the same time we can configure the ServiceHelper using a fake or mock implementation of IServiceProvider when writing unit tests.

Awesome! ๐Ÿ†

Note: I am aware that the ServiceHelper is an implementation of the Service Locator pattern, which is often regarded as an anti-pattern. However, in certain scenarios, especially with regards to .NET-baseed UI technologies, the Service Locator pattern is a necessity.

Lifetime of Dependencies

As you may have noticed before, we have so far registered the dependencies using a method called AddSingleton<T>(). This group of methods can be used to register a single instance (hence singleton) of a class, which means that every time a dependency gets resolved, the exact same instance will be provided by the IoC container during the entire lifetime of the app.

There are three different lifetime modes for dependencies:

  • Singleton

  • Transient

  • Scoped

The first one, as already mentioned, registers a single instance which will be kept alive during the entire lifetime of our app. This is done via the AddSingleton<T>() methods.

Note: Here, the term singleton does not mean the Singleton Design Pattern.

The second mode is called transient and means that whenever a dependency registered with this mode gets resolved, a fresh instance of the registered type is provided. Transients can be registered using the AddTransient<T>() method(s).

Last but not least, there is the scoped mode, which is a little tricky to understand. Basically, scoped services or dependencies share the same lifetime as the Page or object that resolves them. If a non-singleton Page is closed or an object goes out of scope, so does the scoped dependency. This means resolving the same dependency multiple times within the same scope will always yield the same instance, while resolving the same dependency in different scopes will yield different instances. This can be particularly useful when components (e.g. Views) of the same Page share the same dependencies while different Pages should each have their own instance. Scoped dependencies can be registered using the AddScoped<T>() method(s).

Note: There are multiple methods for each type, because you can register contracts (e.g. interfaces and abstract base classes) together with a specific type as well as instances and factories.

Personally, I do not need the scoped mode very often, I prefer the singleton and transient modes as they are sufficient for the more common scenarios.

Common Pitfalls and Gotchas!

There are a couple of common mistakes and issues that I have noticed some people struggling with over the last months, there have been various Stack Overflow questions related to these problems. To make your life easier, I want to address a couple of them here.

Always register all dependencies

Make sure to always register all dependencies including any classes that require these dependencies, otherwise you may see strange exceptions and errors.

One common mistake that I see people make very often is that they register their ViewModels, but they don't register the Pages that use those ViewModels in their constructor and then they see the following runtime error:

System.MissingMethodException: 'No parameterless constructor defined for type 'MauiSamples.Views.MainPage'.'

This can be resolved by registering the MainPage along with the MainViewModel:

builder.Services.AddSingleton<MainViewModel>();
// do NOT forget to also register the MainPage!
builder.Services.AddSingleton<MainPage>();

The same applies to any other dependencies that are automatically resolved. If you have a ViewModel that takes a dependency as a constructor parameter, then you need to register the ViewModel and all of its dependencies.

Automatic constructor injection

Automatic resolution only works in the context of Shell, because Shell takes care of construction for you. If you use constructor injection without Shell by manually creating instances of your classes through explicit constructor calls, then you need to specify the dependencies in the constructor call yourself.

For example, when you use automatic resolution in your MAUI Shell app, but you also write unit tests for your ViewModel, which has no parameterless constructor, then you need to provide the dependencies (e.g. by using mocks or fakes) yourself:

[Test]
public void SomeMethod_ArgumentsAreValid_ReturnsTrue()
{
    //arrange
    var deviceServiceMock = new Mock<IDeviceService>();
    var vm = new MainViewModel(deviceServiceMock.Object);

    //act
    var result = vm.SomeMethod(1,2,3);

    //assert
    Assert.That(result, Is.True);
}

The same applies when you manually create Page instances instead of using routes, which then leads to the creation of a Navigation Stack, which is not governed by Shell.

Resolving unknown types

If you attempt to resolve a type that has not been registered, you will see an exception. My recommendation is to not "swallow" this exception by using a catch-all clause. Instead, you should let your app crash in this case, because mandatory dependencies are required for the correct functioning of an app and it's usually the developer's fault when the required dependencies don't get registered. Fail early, fail often is the premise here.

Adding dependencies during runtime

Unfortunately, it's not possible to register, replace or update dependencies during the lifetime of a .NET MAUI app, because the service collection cannot be modified after the application is built. Bummer. If you have a scenario where you need to modify the dependencies frequently, e.g. based on user settings, etc. then you can either register a service provider that can handle this for you or you can use a third-party DI framework like the ones I've mentioned earlier.

Conclusions and Next Steps

Dependency injection is a powerful first-class citizen in .NET MAUI and comes directly built-in, which is awesome, because we don't have to use any third-party frameworks or libraries, as long as we do not need to do anything beyond the restrictions and limitations of the built-in dependency injection mechanism.

Today, I have shown you how to leverage Shell's automatic constructor injection as well as ways to manually resolve dependencies. I might write another blog article on dependency injection and unit tests in .NET MAUI in the future. There are viable solutions to this problem, e.g. by mocking the IServiceProvider and using it with the static ServiceHelper class. Let me know if this is something you would like to read more about.

I hope this is useful for you. If you enjoyed this blog post, then follow me on LinkedIn, subscribe to this blog and star the GitHub repository for this post so you don't miss out on any future posts and developments.

ย