This post addresses the following problem:
- How to handle routing between IRoutableViewModels by relying on constructor injection and the service container without creating circular dependencies
Note: I do not claim expertise on any of the topics and technologies herein discussed; however,the proceeding solution appears to have solved this problem for my application.
Backdrop - Dealing With Service Classes
You have created an application with AvaloniaUI or a similar framework which integrates with ReactiveUI, and conforms to the Model-View-ViewModel (MVVM) pattern.
You have implemented a service-container within the app's composition-root which utilizes constructor-based dependency specification and injection (possibly using Microsoft.Extensions.DependencyInjection), believing this constitutes a higher quality solution than the service-locator pattern implicit and default in ReactiveUI through the closely associated Splat library.
Your service class references have been wrangled and domesticated, and view-model classes can now rely on service-class's important functionality without worrying about the details of their implementation nor of their constructor-signatures and dependency chains.
One critical advantage of composition-root constructor-based dependency injection is a shift to reliance on interface over implementation. But another advantage is greater adherence to the DRY principle: service-class constructor calls are no longer scattered across the application, so the burden of needing to modify these calls whenever an injected class's signature is updated is done away with.
Adding a service dependency to a view-model class is as simple as registering that service class with the service-container and specifying a view-model constructor parameter with the corresponding registered service-interface type. All is well with relations between classes of the Model and View Model layers.
View Model Routing
Now you have turned your attention to interactions between classes within the View Model layer, to address the need for routing. You understand the relationship between IScreen and IRoutableViewModel and have a need to navigate between IRoutableViewModel-implementing classes as part of the flow, business-logic and user-experience of your application.
You would like IRoutableViewModels to be able to reference other IRoutableViewModels when invoking their HostScreen property to execute routing commands. An initial attempt to address this desire has resulted in IRoutableViewModel-implementing classes which feature one or more calls to their HostScreen's Router's navigation commands.
One of these calls exists for each unique destination view model which can be navigated to from the present (source) view model. These navigation commands, such as Router.Navigate, require an argument of type IRoutableViewModel representing the destination view-model to be navigated to.
// inside class InitialActiveViewModel, which implements IRoutableViewModel
public InitialActiveViewModel(IScreen screen=null)
{
...
HostScreen = screen;
// assuming SecondViewModel also implements IRoutableViewModel
NavigateToSecondViewModel =
ReactiveCommand.CreateFromObservable( () =>
HostScreen.Router.Navigate
.Execute(new SecondViewModel(HostScreen)));
...
}
In this case, a sufficient IRoutableViewModel-implementing class constructor is invoked directly in order to satisfy this argument. But even if this IRoutableViewModel was defined and assigned to a property before this point in execution and that property was then passed as the command argument, an explicit constructor call for the destination view-model class still had to be made at some point.
Destination view-model classes must always be referenced by implementation during assignment; even if unique interfaces are created for each IRoutableViewModel (probably defined as sub-interfaces of IRoutableViewModel), with the current approach, somewhere along the way a constructor call will have to be made to assign an instance of the appropriate view-model to the variables or properties with those new interface types.
This implies any dependencies expected by the destination view-model constructor must be provided by the source view-model. This creates a burden on the source view-model to have access to service class references which otherwise it may have no need for.
If an IRoutableViewModel can be navigated-to from multiple other view models, code duplication is inevitable in the form of corresponding constructor calls in each of those source view-model class definitions. If dependencies needed by the destination IRoutableViewModel constructor change, every call to that constructor must also be modified in turn.
Bad Solutions
The burden of tracking nested dependencies and desire to reduce code duplication echo motivations for handling service classes via the service container and constructor-injection. An obvious first thought may be to deal with view-models in a similar manner. I certainly tried this with TaskVault. The fatal oversight of this strategy can be summarized in two words: "circular dependency".
// InitialActiveViewModel.cs
/// THIS IS A BAD IDEA
ISecondViewModel _secondViewModel;
public InitialActiveViewModel(IScreen screen, ISecondViewModel secondViewModel)
{
...
// assuming ISecondViewModel has been registered in the service container
_secondViewModel = secondViewModel;
...
}
Unless all IRoutableViewModels within an application and their routing relationships can be modeled as a unidirectional tree (if each view model is a vertex and each routing relationship is an edge, then there should be no cycles in the generated graph), attempting to register these view-models with the service container will result in an infinite-loop at the time of constructor injection.
RoutableViewModelA needs to receive RoutableViewModelB, but when attempting to instantiate and assign a reference to RoutableViewModelB, the service container identifies RoutableViewModelA as a dependency of RoutableViewModelB, so tries to instantiate RoutableViewModelA, and the circus continues.
Global access to the service-container would allow circumventing this dilemma, but this is merely reneging on commitment to composition-root and constructor injection, and returning to the service-locator pattern. We're better than this.
The IRoutableViewModel Factory Service Class
As it turns out, an answer to this problem can be found in the concept of factory service classes. These are classes which belong to the Model layer as service classes, in this case serving as factories for classes which implement IRoutableViewModel.
The factory service class consists of a set of factory methods, each dedicated to the creation of a specific view-model class. In order to capture all the services required by these view-model classes from the service container via constructor injection at compile-time, the factory service class specifies properties for every service class required by the view-model classes under its concern. Whether a service class is needed by every view-model the factory class can create, or merely a single such view-model, the factory class must specify this service in its constructor.
// RoutableViewModelFactory.cs
public class RoutableViewModelFactory : IRoutableViewModelFactory
{
private readonly IApplicationDataStorageService _applicationDataStorageService;
private readonly ILocalVaultBuilderService _localVaultBuilderService;
private readonly IBitmapAssetValueConverter _bitmapAssetValueConverter;
public RoutableViewModelFactory(ILocalVaultBuilderService localVaultBuilderService,
IApplicationDataStorageService applicationDataStorageService, IBitmapAssetValueConverter bitmapAssetValueConverter)
{
_localVaultBuilderService = localVaultBuilderService;
_applicationDataStorageService = applicationDataStorageService;
_applicationDataStorageService);
_bitmapAssetValueConverter = bitmapAssetValueConverter;
}
...
}
A corresponding interface can be defined for this new service class.
// IRoutableViewModelFactory.cs
public interface IRoutableViewModelFactory
{
public LocalVaultSelectViewModel CreateLocalVaultSelectViewModel(IScreen screen);
public NewLocalVaultFormViewModel CreateNewLocalVaultFormViewModel(IScreen screen);
public EncryptionAtRestFormViewModel CreateEncryptionAtRestFormViewModel(IScreen screen);
public ConnectToRemoteFormViewModel CreateConnectToRemoteFormViewModel(IScreen screen);
public MainMenuViewModel CreateMainMenuViewModel(IScreen screen, LocalVaultModel activeLocalVault = null);
...
}
Then, instead of registering individual view-model classes with the service container, the factory service class is registered and then specified as a dependency in the constructor of each view-model class in need of dynamic access to IRoutableViewModels (as for navigation logic).
// ServiceContainer.cs
private void RegisterCustomServices()
{
_services.AddSingleton<ILocalVaultBuilderService, LocalVaultBuilderService>();
_services.AddDbContextFactory<ApplicationDbContext>();
...
_services.AddSingleton<IRoutableViewModelFactory, RoutableViewModelFactory.RoutableViewModelFactory>();
...
}
As there should be no occasion for any other service class to require a reference to this IRoutableViewModel factory service, the circular-dependency problem has now been dealt with. Moreover, any changes to relevant view-model constructor signatures now require only a single update in the factory class definition, as this is now the only place those constructors are called from.
For those who find creating interfaces for view-model classes excessive, this approach solves the previously noted problems without requiring interfaces to be created for each class implementing IRoutableViewModel.
At this point I have decided to create multiple RoutableViewModel factories but this is based on the peculiarities of TaskVault and is subject to change based on how well the approach appears to scale.
To my thinking, as a general rule, any time objects need to be instantiated at run-time AND have access to the service-container which is restricted to the app's composition root, define intermediary factory service classes which can be generated at compile-time, injected into view-model classes also created at compile-time and then referenced at run-time to dynamically create service-accessing objects.
Note, this does not create global state because all service registrations are confined to the composition-root, and registrations are locked-in at compile-time. Additionally, unlike with the service-locator pattern, view-model class dependencies are not obscured by the use of factory-services.
Conclusion
Hopefully this solution will be of some service to you. I am far from a seasoned software developer and this is no less the case for native desktop application development.
However, I enjoy sharing discoveries and improvements I encounter and engage with as I go about my projects, insights often owing to the work and assistance of online content from other developers. If you are working on an AvaloniaUI application or using a similar Xaml based framework, or if you are interested for other reasons, please feel free to peruse the source code for TaskVault, available here.
Advertisement
(the following content may include affiliate product/ service links for which the author receives commission payments. The following is NOT a sponsorship and does not imply endorsement by any external entities of this author, website or blog post, nor of any opinions expressed thereby or therein.)
If you are learning AvaloniaUI, WPF, UWP, Xamarin or any other framework which integrates with ReactiveUI, I highly recommend purchasing a copy of Kent Boogaart's You, I and ReactiveUI. The content covered in this book is unmatched by any online resource I have yet come across, and not for lack of searching.