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
- Isolation: Assemblies loaded into separate custom contexts can reference different versions of the same assembly without conflict.
- Dynamic Resolution: Developers can override default
loading logic via the
Load(AssemblyName)method or subscribe to theResolvingevent to locate assemblies from arbitrary paths or byte arrays. - Collectibility (Unloading): Contexts created with
isCollectible: truecan 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:
- Contract Assembly: A shared assembly containing
interfaces or abstract base classes (e.g.,
IPlugin). This assembly must be loaded into theAssemblyLoadContext.Defaultso that both the host and the plugin share the exact same runtime types. - Plugin Implementations: Separate class libraries implementing the contract. These are compiled against their own private dependencies and output into isolated directories.
- 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:
- Instantiate the context and execute the plugin code within an
isolated method (marked with
[MethodImpl(MethodImplOptions.NoInlining)]). - Track the lifetime of the context using a
WeakReference. - Call
loadContext.Unload(). - 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
AssemblyDependencyResolverrather than manual path resolution to ensure multi-platform native binaries and transitive dependencies from NuGet packages are correctly mapped.