THIS IS A WORK IN PROGRESS ARTICLE! I AM ACTIVELY EDITING IT RIGHT NOW!

there are LOTS of typos as I have not checked this text in grammarly yet…

Chapters

Writing those takes time. Expect to see one published per one-two weeks.

  1. Idea, Stack

  2. Loading plugins

  3. PluginBase, IPlugin

  4. Creating plugin, DependencyInjection

  5. Controllers, Views

  6. Hooks and Triggers - better event system

  7. Advanced: Unit tests, unloading plugins


Dynamic loading

C# being a compiled language requires to dynamically load compiled assemblies in memory using reflection in order to modify the application. In this part I will tell you about .net’s built-in methods to do this. Along with achieving the needed result: assemblies being loaded, - we’ll solve problems like findng plugin’s main .dll, dependency resolving and avoiding dependency version conflicts.


What do we load? (Plugin folder, Manifest.json)

First of all, we need to determine where do we look for the .dll files that we want to load. My solution is simple: We will create a folder /Plugins in the app’s current directory and store plugin’s files in folders with this structure:



Plugins
- ExamplePlugin
- - ExamplePlugin.dll
- - ExamplePlugin.deps.json
- - ExamplePluginDependency.dll
- - manifest.json
- - Assets/...
- NicePlugin
- - NicePlugin.dll
- - NicePlugin.deps.json
- - manifest.json

Now we need a way to determine: which .dll file is the main plugin file. I decided to create a manifest.json file - a file that contains plugin’s metadata: name, version, author and main .dll file name.

Our first step would be to load and parse all manifest.json files, then we load main .dll files, referenced in manifests.



{
  "id": "example.plugin",
  "version": "1.23.4:5678",
  "name": "Example Plugin",
  "assembly": "EamplePlugin.dll"
}

ManifestLoader.cs

Program.cs


AssemblyLoadContext

image placeholder: assemblyLoadContextDiagram.svg

The next question is how are we going to load those .dll files, that we got from manifest.json files?

AssemblyLoadContext is used to isolate groups of loaded assemblies from each other to resolve version conflicts. Let’s say that PluginA references CoolLibrary.Abstractions v.3.1, while PluginB is referencing the same CoolLibrary.Abstractions, but v.2.5. In order for this to work, we will create an AssemblyLoadContext for each main .dll plugin and load it’s dependencies from this context.

Oh right! Plugins have dependencies, but AssemblyLoadContext WOULD NOT find those automagically. We will need to explicitly tell it to look for them. In order to do so, we will create a PluginLoadContext : AssemblyLoadContext. This class will use an AssemblyDependencyResolver class that will read .deps.json file of a provided file and resolve dependencies from it.

.deps.json is created automatically when you compile any csharp project and includes all your dependencies!



public class PluginLoadContext : AssemblyLoadContext
{
    private AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base()
    {
        // initialize the dependency resolver
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        // use the dependency resolver to find all depended libraries and load them
        string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        // if successful, load the main assembly
        if (assemblyPath != null)
        {
            return LoadFromAssemblyPath(assemblyPath);
        }

        return null!;
    }

    // essentially, dark magic. don't think about it 😛
    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        if (libraryPath != null)
        {
            return LoadUnmanagedDllFromPath(libraryPath);
        }

        return IntPtr.Zero;
    }
}

PluginLoadContext.cs


Project template for the plugin file

This part is more about what we will need in the future. As our main application uses the MVC pattern, our plugins would have views and controllers. We should modify our .csproj as follows:



<Project Sdk="Microsoft.NET.Sdk.Razor">

	<PropertyGroup>
		<TargetFramework>net8.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<EnableDynamicLoading>true</EnableDynamicLoading>
		<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
	</PropertyGroup>
	
	<ItemGroup>
		<Reference Include="Prototype.ModularMVC.PluginBase">
			<Private>false</Private>
			<HintPath>..\Prototype.ModularMVC.App\Prototype.ModularMVC.PluginBase\bin\Debug\net8.0\Prototype.ModularMVC.PluginBase.dll</HintPath>
		</Reference>
	</ItemGroup>

	<ItemGroup>
		<FrameworkReference Include="Microsoft.AspNetCore.App">
		</FrameworkReference>
	</ItemGroup>

	<ItemGroup>
	  <Content Update="manifest.json">
	    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
	  </Content>
	</ItemGroup>

</Project>

Explanation:

  • Microsoft.NET.Sdk.Razor will make sure that our .cshtml razor pages will be compiled to classes and added to the output assembly.
  • EnableDynamicLoading hints that this library is intended to be loaded dynamically, rather than executed or referenced directly.
  • Microsoft.AspNetCore.App framework reference will allow us to use ASP.NET-specific classes.
  • AddRazorSupportForMvc speaks for itself, doesn’t it?
  • manifest.json should be created and filled by the author. Then copied to the output directory.
  • Referencing PluginBase as well as the PluginBase library itself will be explained in the next chapter.

Conclusion

At this point, we can locate and load our plugins with their dependencies at runtime.
This article is based on this microsoft learn article, as well as my code base. I have added some abstractions to make it easier to extend my loading mechanism, so the original article might be a little easier to understand. I didn’t simplified my examples because I think that anybody aiming to create the same plugin system as I did will eventually reach similar conclusions and will make similar modifications to the code from the original article.

In the next article, we will talk about communication between the server and loaded plugins, PluginBase library, and using dependency injection to tie everything together.