One of the end goals with these recent posts was to outline a basic plugin architecture for WPF. At it’s core it uses reflection to load the plugins but there were a few gotchas along the way. The first was just getting the assemblies to output the required files and finally was loading those assemblies.

All code is available on GitHub.

Using reflection should be the easy part, just get the types from the dll and look for the class that implements the ChatHandler. For the basic and background job handlers that’s all that was needed. The SignalR chat handler was a different story. It seems like it should be easy but I couldn’t get the projects to output all the required dependent assemblies. Fortunately, Rick Strahl had a great post about this titled, “Getting .NET Library Projects to Output Dependent Assemblies“.

In my project file I want to make two key changes. One is I want it to build to the Plugins directory so I don’t have to keep moving files around every time I make any changes. The other change is to support outputting the dependent assemblies. My background chat handler project ended up looking like:


I changed the output path to copy to a new Plugins directory in my main build so I had a fixed place to load the plugins from. The next bit is the interesting one. That CopyLocalLockFileAssemblies will copy the required nuget package files so that the main project won’t have any dependency on them and won’t even need to know about them.

That’s what I’m trying to go for here. The main Chatter app shouldn’t care what the plugins are doing, as long as the plugins control sending and receiving chats, it shouldn’t matter. The SignalR chat handler had a bunch of dependencies on Asp.Net and SignalR libraries, as I would expect they would so I needed to make sure those were included.

Okay, so I have everything building to one place where I can load from, the Plugins directory of my main project. And the needed libraries should be getting there, but how to load them all? I started by adding a new project I called ChatPluginLoader. In here I added a class called PluginManager that has a static that loads all the needed plugins based on the defined type.

The static looks like:

public static List<T> LoadPlugins<T>(string pluginPath, params object[] objectsOnCtor) where T : class

Its written generically enough that I could use this in other places but it is customized such that T is a class. In the more generic case it might be better to do all these plugins with interfaces but I wanted to force implementers to implement the SendChat specifically with returning a task so it could be run asynchronously. Now, I know that can be worked around but hopefully returning a task and naming the method with a Async suffix will help.

The path to scan for plugins is obvious, but the params here is not quite so. Again, this was written with a general purpose use in mind. As such there are instances where the class being constructed might need values on the constructor. In my case here the classes that implement ChatHandler require a messenger when constructed.

Next important bit in the code is:

        AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolve;

I’m going to do some hand-wavey Wizard of Oz stuff here. Don’t look at the man behind the curtain. The reason I needed this was because, despite loading all the libraries into the current domain, when trying to instantiate the SignalR handler, it couldn’t find the library it needed and would throw a File Not Found exception when resolving the dependent assemblies. I’ll discuss the AssemblyResolve a bit later.

var dllFiles = Directory.GetFiles(pluginPath, "*.dll", SearchOption.AllDirectories);

Next is to get all dlls in the plugins directory. There is one gotcha I ignore, something I didn’t care about but you might. That gotcha is .exe files should also be included as they are also assemblies that should be loaded.

List<Type> classesToLoad = new List<Type>();
foreach (var dllFile in dllFiles)
    var assembly = Assembly.LoadFile(dllFile);

    Type[] types = assembly.GetTypes();
    foreach (Type type in types)
        if (type.BaseType == typeof(T))
foreach (var type in classesToLoad)
    var obj = Activator.CreateInstance(type, objectsOnCtor) as T;
    if (obj != null)

Finally, I iterate through all the dlls I found and look for any that contain the defined base type. Since this includes all dlls this will also load the dependent libraries. I then instantiate the type using Activator.CreateInstance passing in the objects for the constructor and add it to a values list to return.

private static Assembly AssemblyResolve(object sender, ResolveEventArgs e)
        //try and find the assembly in the existing domain
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();
        var existingAssembly = assemblies.FirstOrDefault(x => x.FullName == e.Name);
        if (existingAssembly != null)
            return existingAssembly;

        if (File.Exists(e.Name))
            return Assembly.LoadFrom(e.Name);
        // During probing for satellite assemblies it can happen that an assembly does not exists.
        return null;
    catch (Exception ex)
        Console.WriteLine("AssemblyResolve: " + ex);
        return null;

Above is my AssemblyResolve method I alluded to earlier. The first chunk is the important bit. As mentioned, even when loading the assemblies into the current domain, when trying to resolve all the dependencies the framework couldn’t find what it needed. So this looks for what it might need in the current domain and then returns it.

In my main window view model I needed to add a place to start and stop the plugins since they’re not all going to be running at once. It may very well be that a user would want them to all run at once but I only want one chat handler to be active. I needed to make a change to my abstract ChatHandler:

    public abstract void Activate();
    public abstract void Deactivate();

This gives my plugins a place to start and stop anything it might need for sending and receiving messages. Without this, I was getting duplicate messages showing up because not only was the background job chat handler working, but the SignalR chat handler was as well. This resulted in them both sending new chats to the messenger. By having an Activate and Deactivate, it allows the plugins to start and stop their listening.

Here I bit the bullet and removed any project references to my plugins in the main Chatter app. Without those I would only have plugins if I loaded them from the plugins directory.

In my BackgroundChatHandler I now create and dispose of the timer since it shouldn’t be listening for new chats when it’s not active. Now I need a place to store the plugins and activate and deactivate them. In my MainWindowViewModel I added:

    private ObservableCollection<ChatHandler> chatPlugins = new ObservableCollection<ChatHandler>();
    public ObservableCollection<ChatHandler> ChatPlugins
        get => chatPlugins;

    ChatHandler chatHandler = null;
    partial void OnChatHandlerChanging(ChatHandler? oldValue, ChatHandler newValue)
        if (oldValue != null)
        if (newValue != null) 

The chatHandler already existed but the collection is new and so is the OnChatHandlerChanging. CommunityToolkit.MVVM will allow me to monitor the changing event by defining a partial method here and wiring it all up for me. This makes it super simple to deactivate the unselected chat handler and activate the newly selected one.

Next is to load the plugins and populate them to the collection.

private async Task LoadPluginsAsync()
    var baseDir = AppDomain.CurrentDomain.BaseDirectory;
    var pluginPath = Path.Combine(baseDir, "Plugins");
    var plugins = PluginManager.LoadPlugins<ChatHandler>(pluginPath, Messenger);

    await Dispatcher.InvokeAsync(() =>
        foreach(var plugin in plugins)
        if (ChatPlugins.Count > 0)
            ChatHandler = ChatPlugins[0];

I assume here that the Plugins directory exists but I should really check first. Since the LoadPluginsAsync is likely coming from a thread other than the dispatcher thread I needed to make sure I only worked with view model properties on the dispatcher thread.

And finally I bound everything in my view:

            materialDesign:HintAssist.Hint="Chat Handler"
            ItemsSource="{Binding ChatPlugins}"
            SelectedItem="{Binding ChatHandler}"
            Style="{StaticResource MaterialDesignFilledComboBox}" />

So there are a couple of considerations and problems with the plugin manager code that loads the plugins. First, this isn’t very secure. I blindly trust that any plugins I load have been vetted and work properly. If a plugin crashes it could bring down the app. Second, I don’t need to be loading assemblies that are already loaded. If plugin A and plugin B both use the same assembly there isn’t a need to reload it. This may be an issue that I need to figure out as it still sometimes isn’t able to resolve the assembly and I suspect is the need for the AssemblyResolve to find it.

Next post I’ll get to doing a Blazor chat client and the following post will be a Maui chat client.

I used a few different sources for this post.

  1. The aforementioned post from Rick Strahl, “Getting .NET Library Projects to Output Dependent Assemblies“.
  2. The WPF Plugin Sample on GitHub.
    • This was helpful and very, very thorough but I had problems with it as it’s based on .NET framework and there was some domain stuff that was obsolete or deprecated in .NET 8.
  3. Microsoft’s documentation on Reflection.
  4. Simple Plugin Architecture Using Reflection With WPF Projects over on c-sharpcorner.
    • This is a nice article in that it shows how to use controls from a plugin. .NET framework focused.
  5. WPF – Architecture for Hosting Third-Party .NET Plug-Ins from Microsoft.
    • The article is 8 years old and is focused on .NET framework. I couldn’t get everything to work cleanly in .NET. It’s good info and takes a different approach.


One thought on “Setting up a basic plugin architecture with WPF in .NET

Leave a Reply

Your email address will not be published. Required fields are marked *


928 East Plymouth Drive Asbury Park, NJ 07712