Assembly Loading & LoadContext
Key Points
AssemblyLoadContext(ALC) is .NET Core+'s replacement for AppDomains. Each ALC scopes assembly identity — same name in different ALCs = different types.- Default ALC holds the app and its directly-referenced dependencies. Custom ALCs are how you isolate plugins.
- Type identity is per-ALC. A
Foofrom the default ALC and aFoofrom a plugin ALC are differentSystem.Typeinstances even if the IL is identical. Always cross ALC boundaries via interfaces/abstract types defined in a shared assembly. AssemblyDependencyResolverreads*.deps.jsonto resolve a plugin's dependencies — the right way to build a custom ALC.- Collectibility (
isCollectible: true) lets an ALC + its assemblies be GC-collected. Required for hot-reload scenarios; complicates lifetime. Assembly.LoadFrom/Assembly.LoadFileload into the default or a private ALC; both have type-identity gotchas. Prefer a custom ALC for any plugin work.- Ahead-of-time considerations: NativeAOT does not support dynamic assembly loading. If you ship plugins, you ship JIT.
Concepts (deep dive)
What an ALC is, conceptually
┌────────────────────────────────────────┐
│ Process │
│ │
│ ┌──────────────────┐ │
│ │ Default ALC │ ◄── your app + │
│ │ │ its deps │
│ │ MyApp.dll │ │
│ │ Microsoft.*.dll │ │
│ │ System.*.dll │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Plugin ALC #1 │ ◄── plugin A │
│ │ PluginA.dll │ + private │
│ │ PluginA.Util.dll│ deps │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Plugin ALC #2 │ ◄── plugin B │
│ │ PluginB.dll │ │
│ └──────────────────┘ │
└────────────────────────────────────────┘
Each ALC is its own type-identity universe. Inside an ALC, normal rules apply (one assembly version per simple name). Between ALCs, types with identical IL are different types.
The default ALC
You don't manage it. The runtime creates it at startup, populates it with your app and recursively-referenced assemblies (resolved via the host's binding logic), and that's the universe most code lives in.
AssemblyLoadContext defaultAlc = AssemblyLoadContext.Default;
foreach (var asm in defaultAlc.Assemblies)
Console.WriteLine(asm.FullName);
Custom ALC (the typical plugin shape)
using System.Runtime.Loader;
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath, bool collectible = false)
: base(name: $"Plugin:{Path.GetFileName(pluginPath)}", isCollectible: collectible)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName name)
{
// Try plugin's deps.json first
string? path = _resolver.ResolveAssemblyToPath(name);
if (path != null) return LoadFromAssemblyPath(path);
// Return null to fall back to default ALC.
// Crucially, this is how shared "contract" assemblies get unified.
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string? path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}
}
// Usage:
var alc = new PluginLoadContext("/plugins/MyPlugin/MyPlugin.dll");
Assembly pluginAssembly = alc.LoadFromAssemblyPath("/plugins/MyPlugin/MyPlugin.dll");
Type pluginType = pluginAssembly.GetType("MyPlugin.PluginEntry")!;
IPlugin plugin = (IPlugin)Activator.CreateInstance(pluginType)!;
The crucial design move: Load returns null for assemblies the plugin shares with the host. Falling back to the default ALC is what unifies types across the boundary. Otherwise the plugin loads its own copy of IPlugin.dll and the cast (IPlugin) throws InvalidCastException.
Type identity across ALCs
Default ALC Plugin ALC
┌─────────────┐ ┌─────────────┐
│ IPlugin │ │ IPlugin │ ◄── two distinct Type objects!
└─────────────┘ └─────────────┘
▲ ▲
│ │
│ ┌──────┴──────┐
│ │ PluginImpl │ ◄── implements the plugin-ALC IPlugin
│ └─────────────┘
│
Host code expects an IPlugin from the default ALC.
Cast fails because PluginImpl implements a different IPlugin.
The fix: the contract assembly (IPlugin.dll) lives only in the default ALC and is not duplicated in the plugin's directory. Or, if duplicated, the plugin ALC's Load must return null for it (falling back to default).
Collectibility (hot reload)
var alc = new PluginLoadContext(path, collectible: true);
// ... use ...
alc.Unload();
// Eventually, when no roots remain, the GC collects the ALC + all its assemblies + types.
Caveats:
- Roots prevent collection. A static field, a thread-static, an open exception, or even JIT'd code referenced from elsewhere can keep the ALC alive.
- Use
WeakReference<AssemblyLoadContext>to confirm collection in tests:
void Probe()
{
var alc = new PluginLoadContext(path, collectible: true);
// ... use ...
alc.Unload();
_weak = new WeakReference(alc);
}
// Later:
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.False(_weak.IsAlive); // collected
- Collectible ALCs are slower to load and have a small per-allocation overhead. Use only when you need unload (hot reload, plugin update without restart).
Versioning rules
Inside a single ALC, only one version of an assembly with a given simple name can be loaded. If the plugin requires Newtonsoft.Json 13.0.0 and the host has Newtonsoft.Json 13.0.3, the runtime resolves to 13.0.3 (the higher) within that ALC's bind context. If both want different majors, the bind fails.
Custom ALCs solve this: load Newtonsoft.Json 13.0.0 into the plugin's ALC by not delegating to default for that assembly:
protected override Assembly? Load(AssemblyName name)
{
if (name.Name == "Newtonsoft.Json")
{
// Force isolation — load plugin's own copy
var path = _resolver.ResolveAssemblyToPath(name);
return LoadFromAssemblyPath(path!);
}
// Everything else falls back to default
return null;
}
⚠️ Senior trap: if you isolate
Newtonsoft.Jsonbut pass aJObjectfrom plugin to host, types don't match. Either share the contract or design the plugin contract to use only types defined in shared assemblies.
LoadFrom vs LoadFile
| API | Behavior |
|---|---|
Assembly.LoadFrom(path) | Loads into default ALC's "load-from context" — a sub-context with weird bind rules. Discouraged. |
Assembly.LoadFile(path) | Each call creates a new private ALC for that file. No automatic dependency resolution. Type identity per-call: load the same DLL twice via LoadFile → two different type universes. |
Assembly.Load(byte[]) | "Anonymous" load into default ALC. |
alc.LoadFromAssemblyPath(path) | The right way: explicit ALC, explicit path, predictable identity. |
Modern code uses AssemblyLoadContext.LoadFromAssemblyPath exclusively.
Interaction with NativeAOT
NativeAOT does not support dynamic assembly loading. There's no JIT to compile new IL. Plugins-via-ALC require a JIT'd runtime. If you ship NativeAOT, you ship a closed app. Workarounds:
- Ship pre-compiled "plugin" code — separate native binaries communicating via IPC.
- Use script-style scripting — Roslyn scripting requires JIT, so this also requires non-AOT.
- Static composition — wire all variants at compile time; pick one at runtime via configuration.
How it works under the hood
ALCs are implemented in src/coreclr/binder/ in dotnet/runtime. Each ALC has:
- A name (mostly for diagnostics).
- A dictionary of loaded assemblies keyed by name.
- An
IsCollectibleflag that gates GC participation. - Override points:
Load(AssemblyName),LoadUnmanagedDll(string),Resolvingevent.
The runtime delegates assembly resolution through the ALC: when a method calls into a type whose containing assembly isn't yet loaded, the binder asks the active ALC. If Load returns null, the runtime tries the default ALC.
Type loading interns types by (ALC, fully-qualified name, generic args). Same-name types in different ALCs are stored as different MethodTable (the runtime's internal type representation).
For collectible ALCs, the runtime tracks the assemblies and their JIT'd code in a separate region that can be discarded. The GC walks the ALC's assemblies as roots only while any object of any type from that ALC is alive.
Code: correct vs wrong
❌ Wrong: loading the contract DLL into the plugin ALC
// Plugin folder contains its own IPlugin.dll (duplicated from host).
// Custom ALC's resolver finds it locally → loads into plugin ALC.
// Result: cast (IPlugin)plugin throws.
protected override Assembly? Load(AssemblyName name)
{
// ❌ Resolves IPlugin.dll → loads into THIS ALC
var path = _resolver.ResolveAssemblyToPath(name);
return path != null ? LoadFromAssemblyPath(path) : null;
}
✅ Correct: explicitly defer "shared" assemblies to default ALC
private static readonly HashSet<string> _sharedAssemblies = new()
{
"MyApp.PluginContract"
};
protected override Assembly? Load(AssemblyName name)
{
if (_sharedAssemblies.Contains(name.Name!))
return null; // fall back to default ALC
var path = _resolver.ResolveAssemblyToPath(name);
return path != null ? LoadFromAssemblyPath(path) : null;
}
❌ Wrong: keeping a plugin instance in a static field forever
public static class PluginRegistry
{
private static readonly List<IPlugin> _plugins = new();
public static void Register(IPlugin p) => _plugins.Add(p);
}
If IPlugin is a contract type and _plugins holds plugin-implemented instances, the plugin's ALC is rooted forever via the static field. Collectible ALC will never collect.
✅ Correct: hold via WeakReference (or use a registry that supports unload)
public class PluginRegistry
{
private readonly Dictionary<string, WeakReference> _plugins = new();
public void Register(string name, IPlugin p) => _plugins[name] = new WeakReference(p);
public bool TryGet(string name, out IPlugin? p)
{
if (_plugins.TryGetValue(name, out var weak) &&
weak.Target is IPlugin live)
{
p = live; return true;
}
p = null; return false;
}
}
❌ Wrong: mixing shared and unshared types in plugin contracts
public interface IPlugin { JObject Process(JObject input); }
// JObject is from Newtonsoft.Json. If plugin and host load different versions,
// the JObject types are different — call breaks.
✅ Correct: contract types use BCL types only, or shared types from a versioned contract assembly
public interface IPlugin
{
string Name { get; }
Task<string> ProcessAsync(string inputJson, CancellationToken ct);
}
// String, Task, CancellationToken all live in BCL — guaranteed unified.
Design patterns for this topic
Pattern 1 — "Plugin contract assembly, host-loaded"
- Intent: define the plugin interface in a separate assembly that's only in the host (or, if shipped with plugin, shared via default-ALC fallback).
- When to use it: any plugin architecture.
- Code sketch:
MyApp/
MyApp.exe ← references MyApp.PluginContract
MyApp.PluginContract.dll ← interfaces only
plugins/
PluginA/
PluginA.dll ← references MyApp.PluginContract (not shipped)
PluginA.deps.json
- Trade-offs: plugin authors must reference but not ship the contract.
Pattern 2 — "AssemblyDependencyResolver-driven custom ALC"
- Intent: plugin's
*.deps.jsontells the host where its private dependencies live. - When to use it: any non-trivial plugin with its own NuGet dependencies.
- Code sketch: see "Custom ALC" above.
Pattern 3 — "Collectible ALCs for hot reload"
- Intent: unload-and-reload a plugin without restarting the process.
- When to use it: dev tooling, ASP.NET Core hot reload, scriptable products.
- Trade-offs: strict discipline about what can root the ALC. Test with
WeakReferenceprobes.
Pattern 4 — "Shared base + isolated implementation"
- Intent: stable base interfaces (in default ALC) + isolated plugin code (in custom ALC) + no shared mutable state.
- When to use it: multi-tenant plugin systems where plugins must not see each other's data.
Pattern 5 — "Reflection-only inspection without committing to load"
- Intent: read metadata of a candidate plugin without committing it to a real ALC.
- Code sketch:
using var stream = File.OpenRead(path);
using var pe = new System.Reflection.PortableExecutable.PEReader(stream);
var meta = pe.GetMetadataReader();
foreach (var typeDef in meta.TypeDefinitions)
{
var t = meta.GetTypeDefinition(typeDef);
var name = meta.GetString(t.Name);
Console.WriteLine($"Found: {name}");
}
System.Reflection.Metadata lets you inspect IL without loading. Useful for plugin discovery/validation passes.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Default ALC only | Simplest; no type-identity issues | No isolation; one version per assembly |
| Custom ALC (non-collectible) | Isolation; per-plugin dependencies | Cannot unload; complexity |
| Collectible ALC | Unload + reload | Strict rooting discipline; small overhead |
LoadFrom/LoadFile | Minimal API | Type-identity surprises; deprecated patterns |
| Reflection-only inspection | No commitment to load | Can't execute code |
When to use / when to avoid
- Use a custom ALC for any plugin scenario.
- Use
AssemblyDependencyResolverto read*.deps.json— the canonical resolver. - Use collectible ALCs when you need unload (hot reload, plugin updates).
- Avoid
Assembly.LoadFromin new code — its load-from context behavior is confusing. - Avoid plugin contracts that depend on third-party types (Newtonsoft.Json, FluentValidation) — version skew.
- Avoid ALC isolation in NativeAOT apps — it doesn't work; you have no JIT.
Interview Q&A
Q1. What replaced AppDomains in .NET Core? AssemblyLoadContext. Each ALC scopes assembly identity. Unlike AppDomains, ALCs share a single GC heap and don't provide cross-domain isolation for security; they isolate binding, not execution.
Q2. Why do plugin systems need a custom ALC? 1) Per-plugin dependency versions (without polluting the host's binding). 2) The ability to unload (collectible). 3) Diagnostics (each ALC has a name, easy to attribute "what plugin is this code from?").
Q3. What is AssemblyDependencyResolver for? A helper that reads a plugin's *.deps.json (the file produced by dotnet publish listing all dependencies). Given an AssemblyName, it returns the correct path under the plugin folder. Wires up to a custom ALC's Load override.
Q4. Why does Load returning null matter for shared assemblies? Returning null falls back to the default ALC, which means types from that assembly are unified across host and plugin. Without that, the plugin gets its own copy of (e.g.) IPlugin.dll and casts fail.
Q5. What does collectibility require? 1) isCollectible: true at construction. 2) Eventually no roots (no static fields, no JIT'd code referenced elsewhere, no objects with active threads). 3) Call Unload() and let GC complete.
Q6. How would you prove an ALC really got collected in a test? WeakReference<AssemblyLoadContext> after Unload(), then force GC.Collect() + WaitForPendingFinalizers() + GC.Collect() (twice). Assert weakRef.TryGetTarget returns false.
Q7. What's the difference between LoadFrom and LoadFile? LoadFrom uses the default ALC's "load-from context" — a sub-context with quirky binding rules (assemblies in the load-from list resolve before normal probing). LoadFile creates a fresh private ALC per call, with no automatic dependency resolution and no shared identity across calls. Both are legacy; prefer custom ALC + LoadFromAssemblyPath.
Q8. Why might (IPlugin)pluginInstance throw InvalidCastException even though both define IPlugin? Type identity is per-ALC. If IPlugin.dll got loaded into both default and plugin ALCs, the host's IPlugin ≠ the plugin's IPlugin. Solution: ensure shared contract assemblies resolve via default ALC.
Q9. Can you use plugin loading with NativeAOT? No. NativeAOT lacks a JIT, so it can't execute IL discovered at runtime. Use static composition (compile in all variants) or out-of-process IPC instead.
Q10. How do you avoid loading the same plugin DLL twice? Track which paths you've loaded and return the cached Assembly from the ALC. Or use a single ALC for related plugins. Assembly.LoadFile of the same path twice gives you two distinct ALCs and two distinct copies of every type — almost always a bug.
Q11. What's [ThreadStatic] doing in a multi-ALC world? [ThreadStatic] fields are per-thread, but there's one storage slot per type — and types are per-ALC. So [ThreadStatic] in plugin A and the same field in plugin B (different ALC) are distinct slots, even though the IL is identical.
Q12. Why might a service holding plugin instances cause a memory leak across reloads? Static caches in the host that hold plugin types or instances become roots. The plugin ALC can never collect because its types are reachable. Fix: hold plugin references via WeakReference, or maintain a PluginRegistry that gets cleared on unload.
Gotchas / common mistakes
- ⚠️ Shipping shared contracts duplicated to plugin folders — the contract loads twice; cast fails. Solution: ship contracts only in the host.
- ⚠️ Using
Assembly.GetType("Name")across ALCs — returns the type in the calling assembly's ALC. Specify the ALC explicitly. - ⚠️ Static caches in the host rooting plugin types — block collection.
- ⚠️ Reflection-emit (DynamicMethod) tied to a collectible ALC — pins the ALC's types.
- ⚠️ Not setting
ALC.Resolvingevent — for some scenarios it's the only resolution hook (e.g., types in default ALC trying to reach plugin types). - ⚠️ Plugin DLL changes on disk while loaded — Windows holds file lock unless you
LoadFromStream(load via byte-array). Linux is more permissive but you still see the old bits. - ⚠️ NativeAOT + plugin loading — silently broken. Test on the target deployment mode.