Skip to content
Author: ytianle

C# implementation⚓︎

The C# implementation of a plugin system is closely tied to the .NET runtime. In practice, a plugin is usually a compiled assembly (.dll), and the host uses reflection to discover types that implement a shared contract.

Core idea⚓︎

The usual implementation has three parts:

  • Assembly loading: load the plugin assembly from disk.
  • Interface constraint: require plugins to implement a shared interface such as IPlugin.
  • Reflection-based instantiation: find a compatible type and create an instance at runtime.

.NET-specific implementation⚓︎

Here are some .NET-specific APIs commonly used in a C# plugin system:

  • Plugin loading: AssemblyLoadContext.LoadFromAssemblyName(...) loads the plugin assembly into an isolated load context.1
  • Dependency resolution: AssemblyDependencyResolver helps resolve the plugin's dependencies.2
  • Reflection: Assembly.GetTypes() is part of .NET reflection and is used to discover types that implement the shared contract.3
  • Dynamic instance creation: Activator.CreateInstance() creates the plugin instance dynamically.4

This is where reflection appears in practice: the host inspects types from the loaded assembly and then instantiates a compatible plugin type dynamically.

Minimal example⚓︎

The following simplified version is adapted from the Microsoft tutorial, but split into smaller parts for readability.

1. Shared contract⚓︎

public interface IPlugin
{
    string Name { get; }
    void Execute();
}

2. Host side: load plugin and create instance⚓︎

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;

class Program
{
    static void Main()
    {
        var assembly = LoadPlugin("plugins/HelloPlugin.dll");
        var plugin = CreatePlugin(assembly);
        plugin.Execute();
    }

    static Assembly LoadPlugin(string pluginPath)
    {
        var loadContext = new PluginLoadContext(pluginPath);
        var assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(pluginPath));
        return loadContext.LoadFromAssemblyName(assemblyName);
    }

    static IPlugin CreatePlugin(Assembly assembly)
    {
        var pluginType = assembly
            .GetTypes()
            .First(type => typeof(IPlugin).IsAssignableFrom(type)
                && !type.IsInterface
                && !type.IsAbstract);

        return (IPlugin)Activator.CreateInstance(pluginType)!;
    }
}

3. Isolated load context⚓︎

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

public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

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

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath);
    }
}

Flow⚓︎

  1. The host loads a .dll plugin assembly through a custom load context.
  2. The runtime resolves the plugin's dependencies.
  3. The host scans types in the assembly.
  4. The host finds a class that implements IPlugin.
  5. The host creates the instance dynamically.
  6. The host calls the plugin through the interface.

Notes⚓︎

  • In modern .NET, AssemblyLoadContext is often preferred when you need better isolation or unloading support.
  • The Microsoft tutorial uses this split structure because it scales better than a single Assembly.LoadFrom(...) snippet.
  • In production systems, plugin discovery usually also includes metadata validation, version checks, and error handling.
  • LoadFromAssemblyName(...) starts loading the main plugin assembly into the custom load context. When additional dependencies are needed, the runtime may call the overridden Load(...) method in that context.