Create a .NET Core application with plugins

This tutorial shows you how to:

  • Structure a project to support plugins.
  • Create a custom AssemblyLoadContext to load each plugin.
  • Use the System.Runtime.Loader.AssemblyDependencyResolver type to allow plugins to have dependencies.
  • Author plugins that can be easily deployed by just copying the build artifacts.

Prerequisites

Create the application

The first step is to create the application:

  1. Create a new folder, and in that folder run dotnet new console -o AppWithPlugin.
  2. To make building the project easier, create a Visual Studio solution file. Run dotnet new sln in the same folder.
  3. Run dotnet sln add AppWithPlugin/AppWithPlugin.csproj to add the app project to the solution.

Now we can fill in the skeleton of our application. Replace the code in the AppWithPlugin/Program.cs file with the following code:

using PluginBase;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace AppWithPlugin
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                if (args.Length == 1 && args[0] == "/d")
                {
                    Console.WriteLine("Waiting for any key...");
                    Console.ReadLine();
                }

                // Load commands from plugins.

                if (args.Length == 0)
                {
                    Console.WriteLine("Commands: ");
                    // Output the loaded commands.
                }
                else
                {
                    foreach (string commandName in args)
                    {
                        Console.WriteLine($"-- {commandName} --");

                        // Execute the command with the name passed as an argument.

                        Console.WriteLine();
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }
    }
}

Create the plugin interfaces

The next step in building an app with plugins is defining the interface the plugins need to implement. We suggest that you make a class library that contains any types that you plan to use for communicating between your app and plugins. This division allows you to publish your plugin interface as a package without having to ship your full application.

In the root folder of the project, run dotnet new classlib -o PluginBase. Also, run dotnet sln add PluginBase/PluginBase.csproj to add the project to the solution file. Delete the PluginBase/Class1.cs file, and create a new file in the PluginBase folder named ICommand.cs with the following interface definition:

namespace PluginBase
{
    public interface ICommand
    {
        string Name { get; }
        string Description { get; }

        int Execute();
    }
}

This ICommand interface is the interface that all of the plugins will implement.

Now that the ICommand interface is defined, the application project can be filled in a little more. Add a reference from the AppWithPlugin project to the PluginBase project with the dotnet add AppWithPlugin\AppWithPlugin.csproj reference PluginBase\PluginBase.csproj command from the root folder.

Replace the // Load commands from plugins comment with the following code snippet to enable it to load plugins from given file paths:

string[] pluginPaths = new string[]
{
    // Paths to plugins to load.
};

IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
{
    Assembly pluginAssembly = LoadPlugin(pluginPath);
    return CreateCommands(pluginAssembly);
}).ToList();

Then replace the // Output the loaded commands comment with the following code snippet:

foreach (ICommand command in commands)
{
    Console.WriteLine($"{command.Name}\t - {command.Description}");
}

Replace the // Execute the command with the name passed as an argument comment with the following snippet:

ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
if (command == null)
{
    Console.WriteLine("No such command is known.");
    return;
}

command.Execute();

And finally, add static methods to the Program class named LoadPlugin and CreateCommands, as shown here:

static Assembly LoadPlugin(string relativePath)
{
    throw new NotImplementedException();
}

static IEnumerable<ICommand> CreateCommands(Assembly assembly)
{
    int count = 0;

    foreach (Type type in assembly.GetTypes())
    {
        if (typeof(ICommand).IsAssignableFrom(type))
        {
            ICommand result = Activator.CreateInstance(type) as ICommand;
            if (result != null)
            {
                count++;
                yield return result;
            }
        }
    }

    if (count == 0)
    {
        string availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName));
        throw new ApplicationException(
            $"Can't find any type which implements ICommand in {assembly} from {assembly.Location}.\n" +
            $"Available types: {availableTypes}");
    }
}

Load plugins

Now the application can correctly load and instantiate commands from loaded plugin assemblies, but it is still unable to load the plugin assemblies. Create a file named PluginLoadContext.cs in the AppWithPlugin folder with the following contents:

using System;
using System.Reflection;
using System.Runtime.Loader;

namespace AppWithPlugin
{
    class PluginLoadContext : AssemblyLoadContext
    {
        private AssemblyDependencyResolver _resolver;

        public PluginLoadContext(string pluginPath)
        {
            _resolver = new AssemblyDependencyResolver(pluginPath);
        }

        protected override Assembly Load(AssemblyName assemblyName)
        {
            string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }

            return null;
        }

        protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
        {
            string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
            if (libraryPath != null)
            {
                return LoadUnmanagedDllFromPath(libraryPath);
            }

            return IntPtr.Zero;
        }
    }
}

The PluginLoadContext type derives from AssemblyLoadContext. The AssemblyLoadContext type is a special type in the runtime that allows developers to isolate loaded assemblies into different groups to ensure that assembly versions do not conflict. Additionally, a custom AssemblyLoadContext can choose different paths to load assemblies from and override the default behavior. The PluginLoadContext uses an instance of the AssemblyDependencyResolver type introduced in .NET Core 3.0 to resolve assembly names to paths. The AssemblyDependencyResolver object is constructed with the path to a .NET class library. It resolves assemblies and native libraries to their relative paths based on the .deps.json file for the class library whose path was passed to the AssemblyDependencyResolver constructor. The custom AssemblyLoadContext enables plugins to have their own dependencies, and the AssemblyDependencyResolver makes it easy to correctly load the dependencies.

Now that the AppWithPlugin project has the PluginLoadContext type, update the Program.LoadPlugin method with the following body:

static Assembly LoadPlugin(string relativePath)
{
    // Navigate up to the solution root
    string root = Path.GetFullPath(Path.Combine(
        Path.GetDirectoryName(
            Path.GetDirectoryName(
                Path.GetDirectoryName(
                    Path.GetDirectoryName(
                        Path.GetDirectoryName(typeof(Program).Assembly.Location)))))));

    string pluginLocation = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', Path.DirectorySeparatorChar)));
    Console.WriteLine($"Loading commands from: {pluginLocation}");
    PluginLoadContext loadContext = new PluginLoadContext(pluginLocation);
    return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation)));
}

By using a different PluginLoadContext instance for each plugin, the plugins can have different or even conflicting dependencies without issue.

Create a simple plugin with no dependencies

Back in the root folder, do the following:

  1. Run dotnet new classlib -o HelloPlugin to create a new class library project named HelloPlugin.
  2. Run dotnet sln add HelloPlugin/HelloPlugin.csproj to add the project to the AppWithPlugin solution.
  3. Replace the HelloPlugin/Class1.cs file with a file named HelloCommand.cs with the following contents:
using PluginBase;
using System;

namespace HelloPlugin
{
    public class HelloCommand : ICommand
    {
        public string Name { get => "hello"; }
        public string Description { get => "Displays hello message."; }

        public int Execute()
        {
            Console.WriteLine("Hello !!!");
            return 0;
        }
    }
}

Now, open the HelloPlugin.csproj file. It should look similar to the following:

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

  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>

</Project>

In between the <Project> tags, add the following elements:

<ItemGroup>
<ProjectReference Include="..\PluginBase\PluginBase.csproj">
    <Private>false</Private>
</ProjectReference>
</ItemGroup>

The <Private>false</Private> element is very important. This tells MSBuild to not copy PluginBase.dll to the output directory for HelloPlugin. If the PluginBase.dll assembly is present in the output directory, PluginLoadContext will find the assembly there and load it when it loads the HelloPlugin.dll assembly. At this point, the HelloPlugin.HelloCommand type will implement the ICommand interface from the PluginBase.dll in the output directory of the HelloPlugin project, not the ICommand interface that is loaded into the default load context. Since the runtime sees these two types as different types from different assemblies, the AppWithPlugin.Program.CreateCommands method will not find the commands. As a result, the <Private>false</Private> metadata is required for the reference to the assembly containing the plugin interfaces.

Now that the HelloPlugin project is complete, we should update the AppWithPlugin project to know where the HelloPlugin plugin can be found. After the // Paths to plugins to load comment, add @"HelloPlugin\bin\Debug\netcoreapp3.0\HelloPlugin.dll" as an element of the pluginPaths array.

Create a plugin with library dependencies

Almost all plugins are more complex than a simple "Hello World", and many plugins have dependencies on other libraries. The JsonPlugin and OldJson plugin projects in the sample show two examples of plugins with NuGet package dependencies on Newtonsoft.Json. The project files themselves do not have any special information for the project references, and (after adding the plugin paths to the pluginPaths array) the plugins run perfectly, even if run in the same execution of the AppWithPlugin app. However, these projects do not copy the referenced assemblies to their output directory, so the assemblies need to be present on the user's machine for the plugins to work. There are two ways to work around this problem. The first option is to use the dotnet publish command to publish the class library. Alternatively, if you want to be able to use the output of dotnet build for your plugin, you can add the <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> property between the <PropertyGroup> tags in the plugin's project file. See the XcopyablePlugin plugin project for an example.

Other plugin examples in the sample

The complete source code for this tutorial can be found in the dotnet/samples repository. The completed sample includes a few other examples of AssemblyDependencyResolver behavior. For example, the AssemblyDependencyResolver object can also resolve native libraries as well as localized satellite assemblies included in NuGet packages. The UVPlugin and FrenchPlugin in the samples repository demonstrate these scenarios.

How to reference a plugin interface assembly defined in a NuGet package

Let's say that there is an app A that has a plugin interface defined in the NuGet package named A.PluginBase. How do you reference the package correctly in your plugin project? For project references, using the <Private>false</Private> metadata on the ProjectReference element in the project file prevented the dll from being copied to the output.

To correctly reference the A.PluginBase package, you want to change the <PackageReference> element in the project file to the following:

<PackageReference Include="A.PluginBase" Version="1.0.0">
    <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>

This prevents the A.PluginBase assemblies from being copied to the output directory of your plugin and ensures that your plugin will use A's version of A.PluginBase.

Plugin target framework recommendations

Because plugin dependency loading uses the .deps.json file, there is a gotcha related to the plugin's target framework. Specifically, your plugins should target a runtime, such as .NET Core 3.0, instead of a version of .NET Standard. The .deps.json file is generated based on which framework the project targets, and since many .NET Standard-compatible packages ship reference assemblies for building against .NET Standard and implementation assemblies for specific runtimes, the .deps.json may not correctly see implementation assemblies, or it may grab the .NET Standard version of an assembly instead of the .NET Core version you expect.