Implementing Event-Driven Communication in Avalonia Projects Using MediatR and MS.DI Libraries

Implementing Event-Driven Communication in Avalonia Projects Using MediatR and MS.DI Libraries

AvaloniaUI is a powerful cross-platform .NET client development framework that enables developers to build applications for multiple platforms including Windows, Linux, macOS, Android, and iOS. When building complex applications, modularization and inter-component communication become particularly important. The Prism framework provides a modular development approach with support for hot-plugging plugins, while MediatR is an event subscription and publishing framework that implements the Mediator pattern, making it very suitable for communication between modules and between modules and the main program.

Last updated 3/2/2024 3:45 PM
沙漠尽头的狼
12 min read
Category
Avalonia UI
Tags
.NET C# Avalonia UI Prism MediatR

Hello everyone, I'm the Wolf at the End of the Desert!

AvaloniaUI is a powerful cross-platform .NET client development framework that enables developers to build applications for multiple platforms including Windows, Linux, macOS, Android, and iOS. When building complex applications, modularity and inter-component communication become especially important. The Prism framework provides a modular development approach, supporting hot-plugging of plugins, while MediatR is an event subscription and publishing framework that implements the Mediator pattern, making it ideal for communication between modules and between modules and the main program.

This article focuses on introducing MediatR, which is an open-source simple mediator pattern implementation in .NET. It facilitates request/response, command, query, notification, and event messaging through an in-process messaging mechanism (with no other external dependencies), and supports intelligent dispatch of messages via generics. The open-source library is at https://github.com/jbogard/MediatR.

This article will detail how to use MediatR with Microsoft's Dependency Injection (MS.DI) library in an Avalonia project to achieve event-driven communication.

0. Basic Knowledge Preparation - MediatR Basic Usage

MediatR has two ways of message passing:

  • Request/Response, used for a single Handler.
  • Notification, used for multiple Handlers.

Request/Response

Request/Response is somewhat similar to HTTP's Request/Response: issuing a Request yields a Response.

Request messages in MediatR come in two types:

  • IRequest<T> returns a value of type T.
  • IRequest does not return a value.

For each request type, there is a corresponding handler interface:

  • IRequestHandler<T, U> implements the interface and returns Task<U>
  • RequestHandler<T, U> inherits from the class and returns U
  • IRequestHandler<T> implements the interface and returns Task<Unit>
  • AsyncRequestHandler<T> inherits from the class and returns Task
  • RequestHandler<T> inherits from the class and returns nothing

Notification

Notification is exactly that: a notification. The publisher issues it once, and multiple handlers can process it.

1. Preparation

First, ensure that the necessary NuGet packages are installed in your Avalonia project. You will need Prism.DryIoc.Avalonia as the dependency injection container, and MediatR for event publishing and subscribing. Additionally, to integrate MediatR into the DryIoc container, you will need the DryIoc.Microsoft.DependencyInjection package (thanks to the netizen for providing the technical solution).

Add the following references in your project's .csproj file or NuGet Package Manager:

<PackageReference Include="Prism.DryIoc.Avalonia" Version="8.1.97.11072" /> 
<PackageReference Include="MediatR" Version="12.2.0" />  
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="8.0.0-preview-01" />

2. Configuring the Container and Registering Services

In an Avalonia project, you need to configure the DryIoc container to use Microsoft's DI extensions and register MediatR services. This is usually done in your main startup class (e.g., App.axaml.cs).

Below is an example of configuring the container and registering services:

namespace CodeWF.Tools.Desktop;

public class App : PrismApplication
{
    // Module injection and other theme-unrelated code omitted; see source at the end for details

    /// <summary>
    /// 1. This method may not be required in lower versions of DryIoc.Microsoft.DependencyInjection (5.1.0 and below)
    /// 2. Required in higher versions; otherwise, an exception is thrown: System.MissingMethodException: "Method not found: 'DryIoc.Rules DryIoc.Rules.WithoutFastExpressionCompiler()'."
    /// Reference issues: https://github.com/dadhi/DryIoc/issues/529
    /// </summary>
    /// <returns></returns>
    protected override Rules CreateContainerRules()
    {
        return Rules.Default.WithConcreteTypeDynamicRegistrations(reuse: Reuse.Transient)
            .With(Made.Of(FactoryMethod.ConstructorWithResolvableArguments))
            .WithFuncAndLazyWithoutRegistration()
            .WithTrackingDisposableTransients()
            //.WithoutFastExpressionCompiler()
            .WithFactorySelector(Rules.SelectLastRegisteredFactory());
    }

    protected override IContainerExtension CreateContainerExtension()
    {
        IContainer container = new Container(CreateContainerRules());
        container.WithDependencyInjectionAdapter();

        return new DryIocContainerExtension(container);
    }

    protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
    {
        base.RegisterRequiredTypes(containerRegistry);

        IServiceCollection services = ConfigureServices();

        IContainer container = ((IContainerExtension<IContainer>)containerRegistry).Instance;

        container.Populate(services);
    }

    private static ServiceCollection ConfigureServices()
    {
        var services = new ServiceCollection();

        // Inject MediatR
        var assemblies = AppDomain.CurrentDomain.GetAssemblies().ToList();

        // Add module injection; before explicitly calling a module type, the module assembly is not loaded into the current application domain `AppDomain.CurrentDomain`
        var assembly = typeof(SlugifyStringModule).GetAssembly();
        assemblies.Add(assembly);
        services.AddMediatR(configure =>
        {
            configure.RegisterServicesFromAssemblies(assemblies.ToArray());
        });

        return services;
    }
}

In the code above, we override the CreateContainerRules, CreateContainerExtension, and RegisterRequiredTypes methods to configure the DryIoc container, and register MediatR services and the corresponding handlers.

Note that when registering MediatR services, we search for and register handlers from the list of currently loaded assemblies. If modules are loaded on demand, ensure that the corresponding modules are loaded before registering the handlers.

Additionally, we demonstrated how to manually add a module assembly to the list for handler registration. This is useful when you need to explicitly control which modules and handlers are registered. However, in most cases, you may prefer a more automated way to load and register modules and handlers (e.g., by scanning a specific directory or using conventions). It depends on your specific requirements and project structure.

Also, pay attention to the comments and notes in the code; they provide additional information about each step and configuration. In a real project, you may need to adjust and optimize based on your project's actual circumstances and requirements. For example, you may need to handle circular dependencies, configure scopes, or use advanced features like interceptors or decorators. Detailed explanations and examples can be found in the DryIoc and MediatR documentation.

3. MediatR's Two Messaging Modes

With the basic knowledge ready, we add a class library project CodeWF.Tools.MediatR.Notifications, and add a request definition (which the main project and module response handlers need to implement):

public class TestRequest : IRequest<string>
{
    public string? Args { get; set; }
}

Add a notification definition:

public class TestNotification : INotification
{
    public string? Args { get; set; }
}

The request and notification definitions have the same structure (different interfaces), both with a single string property.

4. Adding Handlers

The sample project structure is as follows. Since this open source project (link at the end) is part of the author's AvaloniaUI desktop tool project, this article only focuses on the three projects shown in the image below:

Project structure

Add a request-response handler in the AvaloniaUI main project (CodeWF.Tools.Desktop):

public class TestHandler : IRequestHandler<TestRequest, string>
{
    public async Task<string> Handle(TestRequest request, CancellationToken cancellationToken)
    {
        return await Task.FromResult($"Main project handler: Args = {request.Args}, Now = {DateTime.Now}");
    }
}

Add a notification handler:

public class TestNotificationHandler(INotificationService notificationService) : INotificationHandler<TestNotification>
{
    public Task Handle(TestNotification notification, CancellationToken cancellationToken)
    {
        notificationService.Show("Notification",
            $"Main project Notification handler: Args = {notification.Args}, Now = {DateTime.Now}");
        return Task.CompletedTask;
    }
}

Add a request-response handler in the module CodeWF.Tools.Modules.SlugifyString (due to ordering, it will not be triggered; added here only to demonstrate one-to-one response for requests):

public class TestHandler : IRequestHandler<TestRequest, string>
{
    public async Task<string> Handle(TestRequest request, CancellationToken cancellationToken)
    {
        return await Task.FromResult($"Module [SlugifyString] Request handler: Args = {request.Args}, Now = {DateTime.Now}");
    }
}

Add a notification handler (it will be triggered together with the main project's notification handler):

public class TestNotificationHandler(INotificationService notificationService) : INotificationHandler<TestNotification>
{
    public Task Handle(TestNotification notification, CancellationToken cancellationToken)
    {
        notificationService.Show("Notification",
            $"Module [SlugifyString] Notification handler: Args = {notification.Args}, Now = {DateTime.Now}");
        return Task.CompletedTask;
    }
}

The definitions of the response handler classes are similar: upon receiving a request, they return a formatted string; upon receiving a notification, they pop up a prompt indicating where the notification was received, for demonstration purposes.

5. Request and Notification Demonstration

We perform the triggering actions in the module CodeWF.Tools.Modules.SlugifyString. In the ViewModel class of the module, we obtain the sender and publisher instances (ISender and IPublisher) via dependency injection:

using Unit = System.Reactive.Unit;

namespace CodeWF.Tools.Modules.SlugifyString.ViewModels;

public class SlugifyViewModel : ViewModelBase
{
    // Slug conversion related logic code omitted; see source at the end
    
    private readonly INotificationService _notificationService;
    private readonly IClipboardService? _clipboardService;
    private readonly ITranslationService? _translationService;

    public SlugifyViewModel(INotificationService notificationService, IClipboardService clipboardService,
        ITranslationService translationService, ISender sender, IPublisher publisher) : base(sender, publisher)
    {
        _notificationService = notificationService;
        _clipboardService = clipboardService;
        _translationService = translationService;
        KindChanged = ReactiveCommand.Create<TranslationKind>(OnKindChanged);
    }

    public async Task ExecuteMediatRRequestAsync()
    {
        var result = Sender.Send(new TestRequest() { Args = To });
        _notificationService.Show("MediatR", $"Response received: {result.Result}");
    }

    public async Task ExecuteMediatRNotificationAsync()
    {
        await Publisher.Publish(new TestNotification() { Args = To });
    }
}

Click the Test MediatR-Request button to trigger ISender.Send to send a request and get a response; click the Test MediatR-Notification button to trigger IPublisher.Publish to send a notification.

Request effect:

Observing the request effect: although a response was registered in both the main project and the module project, only the main project was triggered.

Notification effect:

A notification handler was registered in both the main project and the module project, so both handlers popped up a prompt.

6. Summary

Why MediatR instead of Prism's Event Aggregator?

The author's tool has an online version (https://blazor.dotnet9.com) and a cross-platform desktop version (AvaloniaUI). Using MediatR in both versions allows reusing most of the event code.

CQRS or DDD?

This section is directly copied from MediatR Practice in .NET Applications - 明志唯新 (yimingzhi.net). You should be able to learn something from it:

As software development advances, patterns and concepts are constantly refreshing in architectures: from distributed to microservices, to cloud-native... The times demand more and more from a programmer, especially server-side programmers. DDD (Domain-Driven Design) is repeatedly mentioned in microservice architectures, and some even say it is a must!

Implementing a perfect DDD is challenging, and many programmers on the front line are still CRUD programmers. So is there a buffer zone between CRUD and DDD? The author of MediatR also discussed this issue in an article, and I largely agree with his basic viewpoint: design serves the application; we should not do DDD for the sake of DDD.

CQRS stands for "Command and Query Responsibility Segregation," which can be understood as read-write separation.

Microsoft's official documentation states:

CQRS separates reads and writes into different models, using commands to update data and queries to read data...

  • Commands should be task-based, rather than data-centric.
  • Commands can be placed on a queue for asynchronous processing, rather than synchronous.
  • Queries never modify the database. The DTO returned by a query encapsulates no domain knowledge.

Benefits of CQRS include:

  • Independent scaling: CQRS allows read and write workloads to scale independently, reducing lock contention.
  • Optimized data schemas: The read side can use a schema optimized for queries, and the write side for updates.
  • Security: Easier to ensure that only proper domain entities perform write operations.
  • Separation of concerns: Separating read and write sides makes the model more maintainable and flexible. Most complex business logic is placed on the write model. The read model becomes relatively simple.
  • Simpler queries: By storing materialized views in the read database, the application can avoid complex joins at query time.

With MediatR, we can easily implement CQRS in our applications:

  • IRequest<> messages ending with Command are commands; their corresponding Handlers perform write tasks.
  • IRequest<> messages ending with Query are queries; their corresponding Handlers perform read operations.

Closing Remarks

MediatR is a simple mediator implementation that can greatly reduce the complexity of our applications, and also allows us to gradually evolve from CRUD to CQRS to DDD. After all, we live in reality; we cannot ignore business realities and purely pursue technology.

The evolution of business technology should be a continuous reform, not a revolution. Epidemics may come and go, but we need to live—and live relatively easily!

References

The examples in the article include the main code, but some details may be missing. The source code link is below; feel free to leave comments for discussion.

Reference article: MediatR Practice in .NET Applications

Source code for this article: GitHub

Keep Exploring

Related Reading

More Articles
Same category / Same tag 8/9/2025

Lang.Avalonia: Avalonia multi-language solution, seamlessly supports three formats: Resx/XML/JSON

This is a multi-language management library designed specifically for the Avalonia framework. It reconstructs multi-language support logic through a plugin-based architecture, not only supporting traditional Resx resource files but also adding support for XML and JSON formats, while providing type-safe resource references and dynamic language switching, making multi-language development simpler and more efficient.

Continue Reading