AssemblyLoadContext and Plugin Loading in .NET C

This article provides an overview of how AssemblyLoadContext enables modern dynamic assembly loading and plugin architectures in .NET using C#. It explores the transition from legacy .NET Framework AppDomain boundaries to .NET Core/.NET 5+ isolation models, details how to prevent dependency conflicts, demonstrates how to implement a collectible custom load context, and outlines best practices for dynamically loading, executing, and unloading plugins without memory leaks.

Understanding AssemblyLoadContext

In .NET Core and .NET 5+, AssemblyLoadContext (ALC) replaces the isolation functionality previously provided by AppDomain in .NET Framework. An ALC is a runtime scope responsible for locating, loading, and managing the lifecycle of managed assemblies and native libraries. Every .NET process contains a default context (AssemblyLoadContext.Default), which loads the primary application assemblies and core runtime libraries.

When building extensible applications, loading plugins into the default context often causes dependency conflicts (the "diamond dependency" problem). If two plugins rely on different versions of the same third-party library, the runtime will lock onto the first version loaded, causing version mismatch exceptions in the second plugin. Custom AssemblyLoadContext instances solve this problem by isolating dependencies into distinct loading scopes.

Key Features of AssemblyLoadContext

  1. Isolation: Assemblies loaded into separate custom contexts can reference different versions of the same assembly without conflict.
  2. Dynamic Resolution: Developers can override default loading logic via the Load(AssemblyName) method or subscribe to the Resolving event to locate assemblies from arbitrary paths or byte arrays.
  3. Collectibility (Unloading): Contexts created with isCollectible: true can be completely unloaded from memory by the garbage collector, releasing locked DLL files and unmanaged resources.

Designing a Plugin Architecture

A robust plugin architecture consists of three distinct components:

  1. Contract Assembly: A shared assembly containing interfaces or abstract base classes (e.g., IPlugin). This assembly must be loaded into the AssemblyLoadContext.Default so that both the host and the plugin share the exact same runtime types.
  2. Plugin Implementations: Separate class libraries implementing the contract. These are compiled against their own private dependencies and output into isolated directories.
  3. Host Application: The main executable that instantiates custom ALCs, loads plugin assemblies, executes their functionality, and handles unloading.

Implementing a Custom AssemblyLoadContext

To load a plugin along with its unique dependencies, implement a custom class that inherits from AssemblyLoadContext and utilizes AssemblyDependencyResolver to discover dependencies defined in the plugin's .deps.json file.

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

public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base(isCollectible: true)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // Resolve the assembly path using the plugin's dependency manifest
        string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath != null)
        {
            return LoadFromAssemblyPath(assemblyPath);
        }

        // Return null to fall back to the default loading context (e.g., for shared contracts)
        return null;
    }

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

        return IntPtr.Zero;
    }
}

Loading and Executing a Plugin

The host application instantiates the custom context, loads the plugin assembly from disk, discovers the implementation via reflection, and casts it to the shared interface.

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

public class PluginManager
{
    public void RunPlugin(string pluginPath)
    {
        var loadContext = new PluginLoadContext(pluginPath);
        Assembly pluginAssembly = loadContext.LoadFromAssemblyPath(pluginPath);

        foreach (Type type in pluginAssembly.GetTypes())
        {
            if (typeof(IPlugin).IsAssignableFrom(type) && !type.IsAbstract)
            {
                var plugin = (IPlugin?)Activator.CreateInstance(type);
                plugin?.Execute();
            }
        }
    }
}

Unloading and Memory Management

Dynamic unloading requires careful handling of object references. An assembly cannot be garbage collected if any references to its types, instances, or metadata persist in the host application.

To safely unload a context:

  1. Instantiate the context and execute the plugin code within an isolated method (marked with [MethodImpl(MethodImplOptions.NoInlining)]).
  2. Track the lifetime of the context using a WeakReference.
  3. Call loadContext.Unload().
  4. Trigger the garbage collector explicitly to reclaim the memory and release file locks.
using System;
using System.Runtime.CompilerServices;

public class PluginUnloader
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static WeakReference ExecuteAndUnload(string pluginPath)
    {
        var context = new PluginLoadContext(pluginPath);
        var weakRef = new WeakReference(context);

        var assembly = context.LoadFromAssemblyPath(pluginPath);
        // Execute logic...

        context.Unload();
        return weakRef;
    }

    public static void UnloadPlugin(string pluginPath)
    {
        WeakReference hostTracker = ExecuteAndUnload(pluginPath);

        // Force GC to reclaim collectible ALC memory
        for (int i = 0; hostTracker.IsAlive && i < 10; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }
}

Best Practices

  • Keep Contracts Minimal: Do not put third-party library dependencies inside the shared contract assembly. Keep contracts lightweight and stable to avoid frequent host recompilations.
  • Avoid Static References: Static fields, event handlers, and thread-local variables in the host that point to plugin instances will prevent AssemblyLoadContext.Unload() from completing garbage collection.
  • Always Use Dependency Resolvers: Rely on AssemblyDependencyResolver rather than manual path resolution to ensure multi-platform native binaries and transitive dependencies from NuGet packages are correctly mapped.