📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is AssemblyLoadContext, and how do you build an unloadable plugin system with it?

Custom and collectible AssemblyLoadContext, AssemblyDependencyResolver, shared contracts and type identity, and diagnosing unload leaks.

Senior Coding Expert assemblyloadcontext clr isolation plugins unloading

🎯 Key points

  • AssemblyLoadContext replaces AppDomains as the unit of assembly isolation in modern .NET
  • Custom contexts override Load and use AssemblyDependencyResolver with the plugin's deps.json
  • Shared contracts must load from the default context to keep type identity
  • Collectible contexts can be unloaded, but only when no references remain
  • Leaks come from statics, caches, events, threads, timers and AsyncLocal values
  • Verify unload with a WeakReference and GC loop; investigate with dotnet-dump gcroot
  • An ALC is not a security boundary; untrusted code needs a separate process

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Key concepts

  • Every process has the default ALC (AssemblyLoadContext.Default) holding the app and framework.
  • Custom ALCs override Load(AssemblyName) to decide where dependencies come from. Returning null delegates to the default context.
  • Type identity includes the ALC: 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.
  • Collectible ALCs can be unloaded when no references to anything inside them remain.
  • AssemblyDependencyResolver reads a plugin's .deps.json to resolve its managed and native dependencies.

2. The plugin contract

// 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.

3. The load context

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;
    }
}

4. Loading and using a plugin

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).

5. Unloading correctly

Unload() is cooperative: the ALC is collected only when no references remain to:

  • objects created from plugin types (including via caches, DI containers, static fields in the host),
  • Type, Assembly, MethodInfo objects from the plugin,
  • delegates targeting plugin methods, event subscriptions (host events holding plugin handlers),
  • threads running plugin code, timers, 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.

6. Limits

  • Not a security sandbox — plugins run with full trust in-process. Untrusted code needs process/container isolation.
  • Not available under NativeAOT (no runtime loading).
  • Unloading is best-effort; complex frameworks may hold references.
  • Native libraries loaded by plugins can't be unloaded reliably.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. ALC resolution order

For an assembly request from code in a custom ALC:

  1. Already loaded in this ALC? Return it.
  2. Call the ALC's Load(AssemblyName) override.
  3. If null, ask the default ALC (which uses TPA).
  4. If still not found, raise Resolving events on the requesting ALC, then AppDomain.AssemblyResolve.

This lets plugins use their own versions of libraries while sharing the framework and contracts.

2. Collectible assemblies

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.

3. Diagnosing unload leaks

  • dotnet-dump analyze → dumpheap -type LoaderAllocator, then gcroot to find what keeps it alive.
  • Visual Studio/dotMemory retention paths.
  • Common culprits: static caches in the host, AppContext switches with types, Thread objects, timers, CancellationToken registrations.

4. Sharing types between host and plugin

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.

5. Alternatives

  • Out-of-process plugins (separate processes communicating via gRPC/HTTP) for isolation and security.
  • WebAssembly-based sandboxes for untrusted extensions.
  • Scripting (Roslyn scripting) — heavy and with similar unload considerations.

Try it yourself

  1. Build a host and two plugins using different versions of the same NuGet package.
  2. Load both and confirm each uses its own version.
  3. Write the weak-reference unload test and make it pass.
  4. Introduce a static cache in the host that holds a plugin object and observe the leak with dotnet-dump.

💻 Code

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);
    }
}

⚠ Common mistakes

1. Loading the contract in each plugin ALC

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.

2. Holding references after 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.

3. Treating ALCs as a security sandbox

What happens: running untrusted code in-process. Why it is wrong: plugins have full access to the process. Do instead: process/container isolation.

4. Using Assembly.LoadFrom for plugins

What happens: loading plugins into the default context. Why it is wrong: version conflicts and no unloading. Do instead: custom collectible ALCs.

5. Not using AssemblyDependencyResolver

What 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.

6. Forgetting EnableDynamicLoading

What happens: plugin builds don't include all dependencies. Why it is wrong: load failures at run time. Do instead: set it in plugin projects.

7. Expecting immediate unload

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.

8. Using plugins with NativeAOT

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes the Load override and fallback Why: Checks the default resolution sequence
    How to answer

    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.

  2. When: Candidate says event subscriptions keep plugins alive Why: Probes event handler leaks in general
    How to answer

    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.

  3. When: Candidate needs to discover plugin types before loading Why: Checks reading metadata without loading
    How to answer

    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.

🛠 Real-world problems: how you handle them

Memory grew on every plugin hot reload because a host cache rooted old contexts
Situation
A reporting server let customers upload report plugins and reloaded them without restarting.
Signals
Memory grew by [about 40 MB] per reload and never fell; `dotnet-counters` showed the assembly count rising.
Investigation
Took a dump with `dotnet-dump`, listed `LoaderAllocator` objects and used `gcroot` to find a static `ConcurrentDictionary<Type, Formatter>` holding plugin types.
Resolution
Made the formatter cache per plugin lifetime and cleared it before `Unload()`; added a WeakReference check after each reload.
Prevention
Added an integration test that loads and unloads a sample plugin 50 times and asserts all contexts are collected.

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.

↩ Usually asked after

More in CLR Fundamentals

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me