Custom and collectible AssemblyLoadContext, AssemblyDependencyResolver, shared contracts and type identity, and diagnosing unload leaks.
AssemblyLoadContext (ALC) is the modern replacement for AppDomains as the unit of assembly isolation. Each ALC has its own set of loaded assemblies, so plugins can bring their own dependency versions. A custom ALC overrides Load and uses AssemblyDependencyResolver to resolve from the plugin's deps.json. Shared contract assemblies must come from the default context (return null from Load), otherwise the same interface loaded twice is two different types and casts fail. With isCollectible: true you can call Unload(), but unloading is cooperative: it completes only when nothing references the plugin's types, objects, delegates or threads. Verify it with a WeakReference to the ALC.
An AssemblyLoadContext (ALC) is a container for loaded assemblies that controls how dependencies are resolved and provides isolation (different versions of the same assembly can be loaded in different contexts). With isCollectible: true, an ALC and everything loaded into it can be unloaded. It's the modern replacement for AppDomains and the foundation for plugin systems. I'd explain the concepts, build a plugin host, and cover the unloading pitfalls.
AssemblyLoadContext.Default) holding the app and framework.Load(AssemblyName) to decide where dependencies come from. Returning null delegates to the default context.IPlugin loaded in the default ALC and IPlugin loaded in a plugin ALC are different types — casts fail. Shared contracts must be loaded once, from the default ALC.AssemblyDependencyResolver reads a plugin's .deps.json to resolve its managed and native dependencies.// Plugins.Abstractions.dll - referenced by host and plugins, loaded ONLY in the default ALC
public interface IReportPlugin
{
string Name { get; }
Task<byte[]> GenerateAsync(ReportRequest request, CancellationToken ct);
}
Plugin projects reference the contract with <Private>false</Private> / <ExcludeAssets>runtime</ExcludeAssets> so the contract DLL isn't copied into the plugin folder (avoids loading a second copy), and set <EnableDynamicLoading>true</EnableDynamicLoading> so their deps.json includes all dependencies.
public sealed class PluginLoadContext(string pluginPath) : AssemblyLoadContext(name: Path.GetFileNameWithoutExtension(pluginPath), isCollectible: true)
{
private readonly AssemblyDependencyResolver _resolver = new(pluginPath);
protected override Assembly? Load(AssemblyName name)
{
// Share contracts and framework with the host: let the default ALC resolve them
if (name.Name is "Plugins.Abstractions") return null;
var path = _resolver.ResolveAssemblyToPath(name);
return path is not null ? LoadFromAssemblyPath(path) : null; // plugin's own dependency versions
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is not null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}
}
public sealed class PluginHandle : IDisposable
{
private PluginLoadContext? _context;
public IReportPlugin Plugin { get; private set; }
public PluginHandle(string pluginPath)
{
_context = new PluginLoadContext(pluginPath);
var assembly = _context.LoadFromAssemblyPath(pluginPath);
var type = assembly.GetTypes().Single(t => typeof(IReportPlugin).IsAssignableFrom(t) && !t.IsAbstract);
Plugin = (IReportPlugin)Activator.CreateInstance(type)!;
}
public void Dispose()
{
Plugin = null!;
_context?.Unload(); // starts unloading; completes when nothing references the ALC
_context = null;
}
}
typeof(IReportPlugin).IsAssignableFrom(t) works because the plugin's IReportPlugin resolves to the default context's contract (via Load returning null).
Unload() is cooperative: the ALC is collected only when no references remain to:
Type, Assembly, MethodInfo objects from the plugin,ThreadLocal, AsyncLocal values,GCHandles, native callbacks.Verify unloading in tests:
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference LoadAndUnload(string path)
{
using var handle = new PluginHandle(path);
handle.Plugin.Name.ToString();
return new WeakReference(handle.Context); // expose the ALC weakly
}
var weak = LoadAndUnload(path);
for (int i = 0; weak.IsAlive && i < 10; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Assert.False(weak.IsAlive);
Common leak sources: logging frameworks caching plugin types, System.Text.Json's metadata cache (call JsonSerializerOptions per plugin), TypeDescriptor caches, static event subscriptions, MVC application parts.
A reporting platform let customers upload report plugins. Initially all plugins loaded into the default ALC: two plugins needing different ClosedXML versions conflicted, and updating a plugin required restarting the service. Moving each plugin into a collectible PluginLoadContext with AssemblyDependencyResolver fixed the version conflicts and enabled hot reload. The first unload attempt leaked: the host's IMemoryCache held report definitions referencing plugin types. Scoping caches per plugin and clearing them before Unload(), verified with the weak-reference test, made unloading reliable.
The interviewer checks real plugin-system experience and runtime understanding.
Green flags: custom Load with AssemblyDependencyResolver, shared contracts from default context, type identity, collectible and cooperative unload, leak sources, WeakReference verification, not a security boundary.
Red flags: suggests AppDomains; expects Unload() to be immediate; loads contracts twice.
For an assembly request from code in a custom ALC:
Load(AssemblyName) override.Resolving events on the requesting ALC, then AppDomain.AssemblyResolve.This lets plugins use their own versions of libraries while sharing the framework and contracts.
Collectible ALCs allocate type and code data in a special loader allocator that can be freed. The runtime keeps a LoaderAllocator object alive while any object/type from the ALC is reachable. Once all references are gone, the GC collects the loader allocator, which frees types, JIT-compiled code and statics.
dotnet-dump analyze → dumpheap -type LoaderAllocator, then gcroot to find what keeps it alive.AppContext switches with types, Thread objects, timers, CancellationToken registrations.Only share contract assemblies (interfaces, DTOs) from the default ALC. Passing types defined in the plugin to the host is fine (as object/interfaces), but host code must not hold them after unload. Data crossing the boundary is safer as plain DTOs from the contract assembly.
dotnet-dump.using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
using Plugins.Contracts; // shared assembly: public interface IPlugin { string Run(string input); }
string pluginPath = Path.GetFullPath(args[0]);
WeakReference contextRef = RunPlugin(pluginPath, "hello");
for (int i = 0; contextRef.IsAlive && i < 10; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Console.WriteLine($"Unloaded: {!contextRef.IsAlive}");
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference RunPlugin(string path, string input)
{
var context = new PluginLoadContext(path);
Assembly assembly = context.LoadFromAssemblyPath(path);
Type pluginType = assembly.GetTypes().Single(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);
var plugin = (IPlugin)Activator.CreateInstance(pluginType)!; // works because IPlugin comes from the default context
Console.WriteLine(plugin.Run(input));
context.Unload(); // starts unloading; completes when nothing references the context
return new WeakReference(context);
}
sealed class PluginLoadContext(string pluginPath) : AssemblyLoadContext(isCollectible: true)
{
private readonly AssemblyDependencyResolver _resolver = new(pluginPath);
protected override Assembly? Load(AssemblyName name)
{
// Shared contracts must come from the host so type identity matches
if (name.Name == "Plugins.Contracts") return null;
string? path = _resolver.ResolveAssemblyToPath(name);
return path is null ? null : LoadFromAssemblyPath(path);
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string? path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
}
}
What happens: plugin folders include the contract DLL and it's loaded per ALC.
Why it is wrong: type identity differs; casts to IPlugin fail.
Do instead: load contracts only in the default ALC.
Unload()What happens: caches, events or DI containers keep plugin objects. Why it is wrong: the ALC never unloads; memory leaks. Do instead: release everything and verify with weak-reference tests.
What happens: running untrusted code in-process. Why it is wrong: plugins have full access to the process. Do instead: process/container isolation.
Assembly.LoadFrom for pluginsWhat happens: loading plugins into the default context. Why it is wrong: version conflicts and no unloading. Do instead: custom collectible ALCs.
AssemblyDependencyResolverWhat happens: manually probing plugin folders. Why it is wrong: misses RID-specific and native dependencies. Do instead: use the resolver with the plugin's deps.json.
EnableDynamicLoadingWhat happens: plugin builds don't include all dependencies. Why it is wrong: load failures at run time. Do instead: set it in plugin projects.
What happens: assuming memory is freed right after Unload().
Why it is wrong: unloading completes after GC once references are gone.
Do instead: design for asynchronous unload and verify.
What happens: planning ALC plugins in an AOT app. Why it is wrong: runtime loading isn't supported. Do instead: JIT-based hosting or out-of-process plugins.
Plugin isolation is an architecture decision with real operational cost.
| Need | Approach |
|---|---|
| Different dependency versions per plugin | Custom ALC per plugin |
| Hot reload without restart | Collectible ALC plus strict lifetime rules |
| Untrusted code | Separate process or container; ALC is not a security boundary |
| Simple extensibility | Load once into default context, restart to update |
A senior answer stresses that ALC isolation is about versions and unloading, not security, and that unloadability must be tested in CI.
Assemblies load lazily: the first time the JIT compiles a method that references a type from an assembly, the runtime resolves and loads it. For the default load context the host builds the Trusted Platform Assemblies (TPA) list from deps.json and the shared framework; that list is where assemblies are found. If resolution fails, the AssemblyLoadContext.Resolving and AppDomain.AssemblyResolve events get a chance. FileNotFoundException means not found, FileLoadException usually means found with the wrong identity or a lower version, BadImageFormatException means wrong format or architecture. Debug with the exception's requested version, the deps.json, COREHOST_TRACE and dotnet-trace loader events.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
Metadata is a set of tables inside the assembly that describe every type, method, field, property, event, attribute and every external type or member referenced. IL refers to them with 4-byte tokens (the high byte is the table, for example 0x06 for MethodDef). The manifest is the part of the metadata that describes the assembly itself: its identity, the assemblies it references, resources and exported (forwarded) types. You can read both without loading anything into the runtime using System.Reflection.Metadata's PEReader and MetadataReader.
How to tell it (STAR)
In [our reporting server], each plugin reload leaked [about 40 MB]. A dump showed a static type cache rooting the old load contexts. I scoped the cache to the plugin and added a test that reloads [50] times and checks every context unloads.
Last updated 31 Oct 2026.