BrandGhost
Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection

Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection

Plugin Loading in .NET: AssemblyLoadContext with Dependency Injection

If you've already committed to a plugin architecture, the conceptual part is settled. You have a host application, a shared contract, and external assemblies that implement it. What you need now is a reliable way to implement plugin loading in .NET without dependency conflicts blowing up in production. That's exactly what AssemblyLoadContext plugin loading solves -- and when you combine it with Microsoft.Extensions.DependencyInjection, you get a complete, modern extensibility system without reaching for legacy frameworks.

This article covers the mechanics of AssemblyLoadContext, how AssemblyDependencyResolver handles plugin-specific dependencies, and how to wire everything into your DI container cleanly.

Why Plugin Loading Is Harder Than It Looks

The first approach most developers try is Assembly.LoadFrom(path). For a single plugin with no dependencies, it works. Add a second plugin that references a different version of the same NuGet package, and the runtime will either load the wrong version or throw a FileNotFoundException because the assembly was already loaded into a context it can't reach.

The core problem is that Assembly.LoadFrom dumps assemblies into the shared default context. Two plugins competing for the same dependency name share one loaded copy -- whichever loaded first wins, and the other plugin may silently run against the wrong version. This makes naive plugin loading in .NET brittle the moment your plugin ecosystem grows beyond a single, dependency-free assembly.

Assembly isolation means each plugin gets its own resolution scope. When Plugin A needs Serilog 3.1.0 and Plugin B needs Serilog 4.0.0, each should find only its own copy. The runtime shouldn't conflate them.

In .NET Framework, AppDomain was the isolation mechanism. It worked, but creating a domain was expensive, and cross-domain communication required serialization or MarshalByRefObject proxies. .NET Core removed multi-AppDomain support entirely -- AppDomain.CreateDomain throws PlatformNotSupportedException on .NET 5+. AssemblyLoadContext was introduced as the modern, lightweight replacement. It lives in the same process and CLR instance but gives each context its own assembly name resolution scope. If you're evaluating your overall plugin architecture in C# for improved software design, understanding this isolation model is a prerequisite.

What Is AssemblyLoadContext?

Every assembly in a running .NET process lives in an AssemblyLoadContext. The default context is where all the host's startup dependencies land -- the runtime itself, your app's packages, the ASP.NET Core pipeline. When you load an assembly "normally," it goes there.

An isolated context is a separate, named instance you create manually. Assemblies inside it resolve dependencies against their own paths rather than the global set. Critically, returning null from the Load override tells the runtime to fall back to the default context -- this is how shared types (like your plugin contract interfaces) stay accessible to both the host and the plugin without being duplicated.

Three scenarios guide when you need isolation:

  • Plugin has no unique dependencies and you fully control it -- loading into the default context is fine.
  • Plugin bundles its own dependencies or may conflict with the host -- create an isolated context.
  • Plugin needs to be unloaded at runtime (hot-reload scenarios) -- create a collectible context.

Here's the minimal PluginLoadContext that covers the first two cases:

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

public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath)
        : base(name: Path.GetFileNameWithoutExtension(pluginPath), isCollectible: false)
    {
        // Resolver reads the .deps.json file next to the plugin DLL
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // Try to resolve from the plugin's own directory first
        string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath is not null)
        {
            return LoadFromAssemblyPath(assemblyPath);
        }

        // Returning null falls through to the default context,
        // which is where the shared contract assembly lives
        return null;
    }

    protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
    {
        string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        return libraryPath is not null
            ? LoadUnmanagedDllFromPath(libraryPath)
            : IntPtr.Zero;
    }
}

The null fallback in Load is the design decision that makes everything work. The contract assembly -- the one defining your IPlugin or IAnalyzer interface -- must come from the default context so the host and plugin share the exact same Type object. If both sides loaded it into their own contexts, IsAssignableFrom would return false even for identical code.

AssemblyDependencyResolver: Handling Plugin Dependencies

AssemblyDependencyResolver reads the .deps.json file generated alongside every compiled .NET project. This file maps assembly names to their physical paths, including transitive NuGet dependencies. When Plugin B needs Newtonsoft.Json 13.0.3, the resolver knows to look in Plugin B's own output folder, not in the host's.

Without this resolver, you'd have to manually enumerate a plugin's folder and match DLL names to assembly names -- fragile and incomplete for complex dependency graphs. With AssemblyDependencyResolver, you point it at the plugin's entry DLL path and it handles the rest from the generated metadata.

The implementation in PluginLoadContext above already integrates it. When the runtime calls Load(assemblyName), you ask the resolver first. If it has a local path, load from there. If not, return null and let the default context handle it. This two-pass resolution is the key pattern for the AssemblyLoadContext plugin loading approach.

One practical constraint: the plugin project's output must include its .deps.json file (it does by default when published). If you're copying only the DLL to a plugins folder, the resolver won't find the dependencies. Always deploy the plugin's full publish output, including the deps file and any private dependency DLLs.

Loading a Plugin Assembly Step by Step

With the context class in place, here's a generic loader that covers the full plugin loading in .NET lifecycle -- path validation, context creation, type discovery, and instantiation:

using System.Reflection;

public static class PluginLoader<TContract>
{
    public static IReadOnlyList<TContract> LoadFromAssembly(string pluginPath)
    {
        if (!File.Exists(pluginPath))
        {
            throw new FileNotFoundException($"Plugin assembly not found: {pluginPath}");
        }

        var context = new PluginLoadContext(pluginPath);
        Assembly assembly;

        try
        {
            assembly = context.LoadFromAssemblyPath(pluginPath);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(
                $"Failed to load plugin assembly from '{pluginPath}'.", ex);
        }

        var contractType = typeof(TContract);

        var implementations = assembly.GetTypes()
            .Where(t => contractType.IsAssignableFrom(t)
                     && t is { IsClass: true, IsAbstract: false })
            .ToList();

        if (implementations.Count == 0)
        {
            throw new InvalidOperationException(
                $"No implementations of '{contractType.Name}' found in '{pluginPath}'.");
        }

        var instances = new List<TContract>();
        foreach (var type in implementations)
        {
            if (Activator.CreateInstance(type) is TContract instance)
            {
                instances.Add(instance);
            }
        }

        return instances;
    }
}

The IsAssignableFrom check uses typeof(TContract) from the host's context. This only works because the contract assembly lives in the default context and the plugin's Load override returns null for it -- causing the runtime to reuse the host's already-loaded copy. If you bypass this and load the contract DLL explicitly into the isolated context, you'll get type mismatch failures at runtime even though both assemblies are identical.

Error handling matters here. If the plugin was compiled against an older version of the contract interface with different method signatures, Activator.CreateInstance will succeed but calling contract methods may fail with MissingMethodException. Version your contract assembly carefully, or use an interface evolution strategy that maintains backward compatibility across plugin releases.

Why MEF Is No Longer the Answer

MEF -- System.ComponentModel.Composition and its successor System.Composition -- was .NET's original framework for plugin composition. It used attributes ([Export], [Import]) to declare extension points and used reflection to wire up parts at startup. For simple scenarios, it looked elegant.

The problems are fundamental. MEF predates AssemblyLoadContext and doesn't integrate cleanly with isolated contexts. Its attribute coupling creates friction in DI-centric codebases -- you end up maintaining two composition systems. It doesn't support IServiceCollection registration without glue code. Microsoft has placed MEF in maintenance mode; it receives security fixes but no new features, and it is not recommended for new development.

The modern answer is AssemblyLoadContext for assembly isolation combined with Microsoft.Extensions.DependencyInjection for composition. This is the same DI container your ASP.NET Core or Worker Service app already uses. You register plugin implementations the same way you register any other service -- no attribute coupling, no second composition layer, no legacy infrastructure. MEF is worth knowing as historical context, but it's not a viable choice for new plugin systems in .NET 8+.

Integrating Loaded Plugins with Microsoft.Extensions.DependencyInjection

The integration challenge is subtle. Correct plugin loading in .NET requires registering by contract interface rather than by concrete type. When a plugin type lives in an isolated AssemblyLoadContext, its Type object is context-specific. The reliable pattern is to register by contract interface -- a type from the shared default context -- and provide the concrete implementation type object directly. Microsoft.Extensions.DependencyInjection accepts a ServiceDescriptor with a concrete Type instance, which bypasses any name-based lookup and uses the type object you already have.

Here's an IServiceCollection extension method that scans a plugin directory and registers all implementations of a given contract:

using System.Reflection;
using Microsoft.Extensions.DependencyInjection;

public static class PluginServiceCollectionExtensions
{
    public static IServiceCollection AddPluginsFromDirectory<TContract>(
        this IServiceCollection services,
        string pluginDirectory,
        ServiceLifetime lifetime = ServiceLifetime.Singleton)
        where TContract : class
    {
        if (!Directory.Exists(pluginDirectory))
        {
            throw new DirectoryNotFoundException(
                $"Plugin directory not found: {pluginDirectory}");
        }

        // Use a naming convention (e.g. "*.Plugin.dll") rather than scanning all DLLs --
        // scanning "*.dll" blindly picks up dependency assemblies and causes false positives.
        var pluginFiles = Directory.EnumerateFiles(
            pluginDirectory, "*.Plugin.dll", SearchOption.TopDirectoryOnly);

        var contractType = typeof(TContract);

        foreach (var pluginPath in pluginFiles)
        {
            var context = new PluginLoadContext(pluginPath);
            Assembly assembly;

            try
            {
                assembly = context.LoadFromAssemblyPath(pluginPath);
            }
            catch
            {
                // Skip assemblies that can't be loaded (e.g., native DLLs)
                continue;
            }

            var implementations = assembly.GetTypes()
                .Where(t => contractType.IsAssignableFrom(t)
                         && t is { IsClass: true, IsAbstract: false });

            foreach (var implType in implementations)
            {
                // Register by contract interface, not concrete type
                services.Add(new ServiceDescriptor(contractType, implType, lifetime));
            }
        }

        return services;
    }
}

Registration in Program.cs is a one-liner:

builder.Services.AddPluginsFromDirectory<IAnalyzer>(
    Path.Combine(AppContext.BaseDirectory, "plugins"));

This approach integrates naturally with plugin architecture in ASP.NET Core and the request pipeline. For teams using Autofac instead of the built-in container, the pattern translates directly -- see Autofac ComponentRegistryBuilder in ASP.NET Core for the equivalent registration mechanics.

Unloading Plugins and Memory Management

If plugins are loaded at startup and live for the process lifetime, unloading doesn't matter. The contexts and their assemblies stay in memory until the process exits, which is the right behavior for most production scenarios.

Hot-reload changes this. If you want to update a plugin without restarting the host, you need to unload the old version and load the new one. That requires a collectible context -- one created with isCollectible: true.

public class UnloadablePluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public UnloadablePluginLoadContext(string pluginPath)
        : base(
            name: Path.GetFileNameWithoutExtension(pluginPath),
            isCollectible: true) // Required for unloading
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        string? path = _resolver.ResolveAssemblyToPath(assemblyName);
        return path is not null ? LoadFromAssemblyPath(path) : null;
    }
}

// Trigger unload and verify via WeakReference
public static void UnloadPlugin(ref UnloadablePluginLoadContext? context)
{
    if (context is null) return;

    var weakRef = new WeakReference(context);
    context.Unload();
    context = null; // Drop the strong reference before GC runs

    // Force GC to collect the context
    for (int i = 0; i < 10 && weakRef.IsAlive; i++)
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }

    if (weakRef.IsAlive)
    {
        // A strong reference is still keeping the context alive
        throw new InvalidOperationException(
            "Plugin context could not be unloaded. Check for lingering references.");
    }
}

The weak reference pattern is Microsoft's recommended verification approach. If weakRef.IsAlive remains true after GC cycles, a strong reference is still rooted somewhere -- commonly a static field holding a plugin type, a delegate that captures a plugin instance, or a cached reflection result. Unloading is all-or-nothing: every strong reference must be dropped before the GC can collect the context.

For production services that load plugins once at startup, use non-collectible contexts. The complexity of safe unloading is real, and a lingering reference can cause memory leaks worse than simply keeping the assembly loaded. Reserve collectible contexts for development-time tooling or explicitly designed hot-reload features.

A Complete Plugin Loading Example

Here's an end-to-end example combining all the pieces. The contract is defined in a shared project referenced by both host and plugins:

// DevLeader.Analyzers.Contracts -- shared assembly in the default context
namespace DevLeader.Analyzers.Contracts;

public interface IAnalyzer
{
    string Name { get; }
    AnalysisResult Analyze(string input);
}

public record AnalysisResult(bool Passed, string Message);

The host scans plugins/, registers all IAnalyzer implementations, and runs them:

// Program.cs
using DevLeader.Analyzers.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

// Scan plugins/ folder and register all IAnalyzer implementations
builder.Services.AddPluginsFromDirectory<IAnalyzer>(
    Path.Combine(AppContext.BaseDirectory, "plugins"));

var host = builder.Build();

// Resolve all registered analyzers via IEnumerable<IAnalyzer>
var analyzers = host.Services.GetServices<IAnalyzer>();
foreach (var analyzer in analyzers)
{
    var result = analyzer.Analyze("sample input");
    Console.WriteLine($"[{analyzer.Name}] {(result.Passed ? "PASS" : "FAIL")}: {result.Message}");
}

Each plugin project references only the contracts assembly and ships its full publish output -- DLL, .deps.json, and any private dependencies -- into the plugins/ subfolder. The AssemblyLoadContext plugin mechanism handles isolation and resolution transparently, making plugin loading in .NET a first-class feature of your application rather than an afterthought. No changes to the host or the contract are needed as you add new plugins.

This is the foundation behind plugin architecture with Needlr in .NET, which builds a higher-level abstraction over these same primitives. For the architectural reasoning behind when to reach for this pattern, the beginner's guide to the plugin architecture design pattern covers the decision criteria. If you're also building AI-powered extensibility, the mechanics differ meaningfully from how custom plugins for Semantic Kernel in C# work -- it's worth understanding both models when designing extensible .NET systems.

Frequently Asked Questions

What is the difference between AssemblyLoadContext and AppDomain?

AppDomain was the isolation primitive in .NET Framework. Creating a domain was expensive (effectively a sub-process within the process), and cross-domain communication required serialization or MarshalByRefObject proxy types. In .NET Core and .NET 5+, multi-AppDomain support was removed entirely -- AppDomain.CreateDomain throws PlatformNotSupportedException. AssemblyLoadContext is the modern replacement. It provides assembly-scope isolation without the process overhead, and cross-context communication is straightforward because both contexts share the same CLR host and the same memory space. Types from the default context are accessible to all isolated contexts. The tradeoff is that AssemblyLoadContext provides assembly isolation only, not memory isolation -- a misbehaving plugin can still affect host memory, unlike a true out-of-process boundary.

Can I load multiple plugins that depend on different versions of the same NuGet package?

Yes -- this is exactly the problem AssemblyLoadContext isolation was designed to solve. Each plugin gets its own PluginLoadContext instance with its own AssemblyDependencyResolver pointing at its own folder. Plugin A can load Newtonsoft.Json 12.0.3 and Plugin B can load Newtonsoft.Json 13.0.3 simultaneously without conflict, because each version is loaded into a separate context. The key constraint is that types flowing between a plugin and the host -- the contract types -- must come from the shared default context. If Plugin A passes a JObject from its local Newtonsoft.Json to the host, the host's type system won't recognize it. Contract types should always be plain types defined in a shared contracts assembly, not types from third-party packages that plugins may version independently.

How do I pass configuration to plugins loaded with AssemblyLoadContext?

The cleanest approach is to define configuration access in your contract interface. Include an Initialize(IConfiguration config) method or accept a configuration POCO defined in the contracts assembly. Because Microsoft.Extensions.Configuration.IConfiguration is a shared assembly loaded in the default context, the host can pass its configuration instance directly to plugin implementations -- no serialization required. Alternatively, define a plain settings POCO in the contracts assembly and populate it before passing it to the plugin. This keeps the plugin's dependency on host infrastructure minimal and makes isolated unit testing of plugins straightforward. Avoid passing IServiceProvider directly into plugins -- it creates a tight coupling to the host's entire service graph and makes the plugin difficult to reason about in isolation.

Is AssemblyLoadContext available in .NET Framework?

No. AssemblyLoadContext is a .NET Core / .NET 5+ API and is not available on .NET Framework 4.x. If you're maintaining a .NET Framework plugin system that uses AppDomain for isolation, the migration path is to target .NET 8 or later, at which point AssemblyLoadContext becomes available and you can remove the AppDomain infrastructure. There is no backport of AssemblyLoadContext to .NET Framework -- the API depends on the unified runtime that .NET Core introduced. If full migration isn't immediately feasible, consider targeting netstandard2.0 for the contracts assembly and keeping the host on .NET 8+, which is the most common phased transition approach.

How do I debug a plugin loaded in a separate AssemblyLoadContext?

Visual Studio and JetBrains Rider both support debugging assemblies in non-default load contexts, provided the plugin's PDB files are present alongside the DLL in the plugins folder. Set isCollectible: false on the context during debugging sessions to prevent unloading from interfering with the debugger's symbol state. You can also embed Debugger.Launch() or Debugger.Break() inside plugin initialization code to force debugger attachment at a known point. For tracing and diagnostics without an interactive debugger, pass an ILogger instance through the contract interface and route all plugin output through the host's logging pipeline -- this keeps telemetry unified in whatever sink the host configures, regardless of which plugin emitted the log entry.

Wrapping Up

AssemblyLoadContext combined with AssemblyDependencyResolver is the correct mechanism for plugin loading in .NET. It solves the dependency conflict problem that makes Assembly.LoadFrom brittle, integrates cleanly with Microsoft.Extensions.DependencyInjection through contract-based registration, and supports unloading for hot-reload scenarios when you need it.

The key decisions to evaluate for your specific implementation: whether plugins need isolated contexts (almost always yes for any real plugin system), whether you need collectible contexts (only for hot-reload), and where your contract assembly lives (always in the default context, never duplicated into plugin contexts).

The pattern scales from simple single-plugin systems to complex extensibility platforms. AssemblyLoadContext plugin loading, paired with the DI container your application already uses, gives you the isolation and control you need without legacy infrastructure or attribute-based coupling. Start with the PluginLoadContext and AddPluginsFromDirectory patterns shown here, validate them against your dependency graph, and extend from that baseline as your plugin system grows.

Blazor Plugin Architecture - How To Manage Dynamic Loading & Lifecycle

Want to have a plugin architecture that supports dynamic loading for Blazor? Follow this tutorial for a Blazor plugin architecture that leverages Autofac!

Plugin Architecture in C# for Improved Software Design

Learn about plugin architecture in C# to create extensible apps! This article provides examples with code snippets to explain how to start with C# plugins.

Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications

Learn how to implement plugin architecture in C# using modern .NET patterns. Complete guide with AssemblyLoadContext, DI integration, and working code examples.

An error has occurred. This application may no longer respond until reloaded. Reload