← Blog

.NET AssemblyLoadContext Plugin Architecture: Shared Contracts, Dependency Resolution, and Unload Verification

.NET AssemblyLoadContext Plugin Architecture: Shared Contracts, Dependency Resolution, and Unload Verification

Loading a DLL is the easy part of a .NET plugin system. The harder engineering work is preserving type identity, resolving each plugin's dependencies predictably, and proving that a collectible loading scope can actually be reclaimed. This guide uses a local .NET 8 test project and reports observed results without relying on an external service or an irreversible operation.

The core AssemblyLoadContext plugin architecture

A dependable AssemblyLoadContext plugin architecture keeps one contract assembly in the host, resolves plugin-owned dependencies inside a dedicated loading context, and treats unloading as a testable lifecycle outcome rather than an assumption made after calling Unload().

AssemblyLoadContext, commonly abbreviated as ALC, is the .NET runtime scope in which managed assemblies are loaded and resolved. It is not merely a directory search feature, and it is not a process-level security boundary. A contract DLL with the same file name and version can still produce incompatible runtime types when one copy is loaded in the default context and another is loaded in a plugin context.

The first design rule is therefore simple: load the contract once. The host references a small contract project. A plugin can reference that project at compile time, but its deployable output should not carry a second copy of the contract DLL. When the custom context receives a request for that contract assembly, its Load override returns null, allowing normal fallback to the copy already loaded by the default context.

The second rule is to let the plugin resolve plugin-owned dependencies. AssemblyDependencyResolver converts the component's dependency information into assembly paths that the custom context can load. This is more predictable than putting every package beside the host executable or recursively searching unrelated directories.

The third rule is to verify unloadability. A collectible context does not disappear immediately when Unload() is called. Unloading is cooperative. Threads, event handlers, static collections, reflection objects, and caches can retain plugin instances or plugin types. A verification harness must release strong references, hold a WeakReference to the context, run bounded garbage collection cycles, and inspect whether the context remains alive.

Why identical contract names can still fail a cast

Runtime type identity includes the loading context, not only the namespace, type name, assembly name, and version. If the host and plugin load separate contract copies, a plugin class may visibly implement IPlugin while still failing the host's IPlugin cast.

Assume the host directly references Contract.IPlugin. If the plugin output also includes Contract.dll, a custom plugin context might load that local file while resolving DemoPlugin. The class then implements the interface from the plugin context. The host attempts a cast against the interface from the default context. Their textual descriptions match, but the runtime identities do not.

The correction has two layers. At build time, configure the plugin project reference with Private="false" and assert that the contract DLL is absent from the plugin output. At runtime, identify the contract assembly in the custom context's Load method and return null. The fallback then uses the host-owned assembly. Both controls matter. An output-only correction can regress when packaging changes, while a runtime-only correction leaves a misleading duplicate in the artifact.

Keep the contract narrow. It should contain the interfaces, simple exchange types, and lifecycle methods that are truly shared. Placing database drivers, logging implementations, or a large domain model in the contract increases version coupling between the host and every plugin. Additive evolution is generally easier to coordinate than changing an existing method signature, but compatibility still needs an executable test. A matching file name is not proof of binary compatibility.

The contract also establishes ownership. The host owns the interface instance it receives and must define when calls may begin, when new work stops, and when references are released. A plugin owns its internal dependencies. This separation makes it possible to reason about deployment output and to diagnose whether a failure belongs to contract identity, plugin resolution, or lifecycle cleanup.

Resolving plugin dependencies with AssemblyDependencyResolver

AssemblyDependencyResolver reads a component's dependency information and returns paths for managed assemblies or native libraries. In a custom ALC, it provides a constrained and explainable resolution strategy, but it does not replace contract design, lifecycle management, or a stronger isolation boundary.

A typical context receives the absolute path of the plugin's main assembly and constructs one resolver. The overridden Load method first excludes the shared contract. It then calls ResolveAssemblyToPath for plugin-owned assemblies. If no path is returned, the override returns null so that the runtime can continue normal fallback behavior. Native resolution can be implemented separately through LoadUnmanagedDll and ResolveUnmanagedDllToPath; the local verification in this article intentionally covers managed assemblies only.

sealed class PluginContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver resolver;

    public PluginContext(string pluginPath)
        : base(isCollectible: true) => resolver = new(pluginPath);

    protected override Assembly? Load(AssemblyName name)
    {
        if (name.Name == typeof(IPlugin).Assembly.GetName().Name)
            return null; // Reuse the contract from the default ALC.

        var path = resolver.ResolveAssemblyToPath(name);
        return path is null ? null : LoadFromAssemblyPath(path);
    }
}

The important feature is the resolution order. Shared boundaries are handled explicitly before plugin-owned dependencies. Unresolved names return to normal behavior instead of triggering a broad directory scan. This keeps the dependency graph aligned with build output and makes a missing assembly diagnosable.

A file merely existing in an output directory does not guarantee that the default context will resolve it. The default context uses application dependency information and runtime rules. Copying an unreferenced DLL as generic content is not a durable dependency declaration. If the host genuinely needs an additional component, express that relationship through a project or package reference, or implement a narrow and testable resolving policy. Then test the complete host startup path rather than inferring host behavior from a plugin-only unit test.

AssemblyDependencyResolver also does not select arbitrary compatible versions. A loading context can load only one version for a given simple assembly name, and a request is satisfied according to runtime versioning rules. If plugins need conflicting dependency versions, separate plugin contexts can provide useful isolation. If two components must exchange a type directly, however, that exchanged type belongs in the shared contract boundary.

What collectible unloading does and does not guarantee

Collectible unloading is cooperative: Unload() initiates the operation, but reclamation occurs only after no thread is executing plugin code and no strong reference outside the context retains its assemblies, types, methods, or objects.

A sound harness moves loading and invocation into a method marked NoInlining. It creates the context, loads the plugin, invokes the contract, stores a weak reference to the context, clears local strong references, calls Unload(), and returns the weak reference. The caller performs a bounded sequence of GC.Collect(), GC.WaitForPendingFinalizers(), and another collection. The check passes only when WeakReference.IsAlive becomes false within the stated bound.

Common roots include a host collection that still stores the plugin instance, an event subscription whose publisher outlives the plugin, a background thread executing plugin code, a static field, an exception object, or a cache in another context that stores plugin reflection metadata. Cleanup therefore needs a protocol. Stop accepting new plugin work, signal existing work to finish, wait for execution to leave plugin code, detach events, dispose resources, remove host-held references, and only then request unloading.

One successful weak-reference check proves only the exercised path. It does not prove that every workload can unload. A useful test matrix includes load-only behavior, the main execution path, error handling, and commonly used data transformation paths. If one path creates a long-lived root, the test should report that the context remains alive. Repeating garbage collection forever would hide rather than solve the ownership problem.

Not every plugin host needs collectible contexts. If plugins are discovered only at startup and a local test host can restart for updates, a non-collectible context may be simpler. Collectibility is valuable when replacing plugins without stopping the host is a real requirement, but it adds lifecycle responsibilities. ALC isolates assembly loading; it does not make untrusted code safe. Such code requires a stronger process or sandbox boundary.

Verification and reproduction in a fixed .NET 8 test project

This verification was executed on Windows with .NET SDK 8.0.100. The local build completed, the plugin output omitted the contract DLL, the host cast succeeded, the plugin returned the expected marker, and the collectible context became unreachable after bounded collection cycles.

The prerequisite is .NET SDK 8.0.100. Create three disposable local projects named Contract, Plugin, and Host. Pin the SDK in global.json. Define IPlugin in Contract. Implement it in Plugin and set the project reference to Private="false". Reference Contract from Host, then use the PluginContext shown above. No remote system or persistent data is required.

  1. Run dotnet --version from the test root. Pass only when the exact output is 8.0.100. Stop if that SDK is unavailable or another version is selected, because a different runtime result is not evidence for this fixed test.
  2. Run dotnet build Plugin/Plugin.csproj -c Release --nologo. Pass when the process exits with code zero, reports zero errors, and Plugin/bin/Release/net8.0/Contract.dll does not exist. Any copied contract makes this artifact check fail.
  3. Run the Host project with the absolute plugin DLL path. Pass only when the observable output includes SHARED_CONTRACT_CAST=True, PLUGIN_RESULT=plugin-ok, and COLLECTIBLE_UNLOADED=True. A missing marker or false value is a failure.
dotnet --version
dotnet build Plugin/Plugin.csproj -c Release --nologo
dotnet run --project Host/Host.csproj -c Release -- \
  "C:/sandbox/alc-check/Plugin/bin/Release/net8.0/Plugin.dll"

The scheduler run selected SDK 8.0.100. The Contract and Plugin projects built with zero errors. The artifact assertion reported that the contract was absent from the plugin output. The Host then reported a successful shared-contract cast, the expected plugin-ok result, and successful collectible unloading. These are observed functional results, not a benchmark. No loading latency, memory reduction, throughput, or success-rate figure is claimed.

A negative cast test can be performed only in a disposable copy: change the plugin reference so that the contract is copied, and change the custom context so that it loads that local contract. The expected outcome is that the host's contract cast fails. Do not perform this experiment against an existing deployment folder, because the purpose is to expose a broken type boundary in an isolated test project.

For unload diagnostics, change one variable at a time. First keep the plugin instance in a host collection and observe that the weak reference remains alive. Then remove that reference and rerun. Next add and remove an event subscription. This sequence distinguishes a lifecycle root from a dependency-resolution problem. Record only the marker actually observed in each run.

Deployment discipline and architectural trade-offs

A maintainable plugin deployment makes contract ownership, dependency location, update sequencing, and failure conditions explicit. If success still depends on a DLL happening to sit in a convenient folder, the loading boundary is not yet deterministic.

Use the following review checklist:

  • The host loads exactly one contract assembly, and the build checks that plugin output does not contain another copy.
  • The custom ALC excludes the contract before asking AssemblyDependencyResolver for plugin-owned dependencies.
  • Each plugin's main assembly and dependency description are deployed together as one coherent artifact.
  • The host does not retain plugin objects, reflection metadata, event handlers, or exceptions beyond the documented lifecycle.
  • A collectible scenario has a bounded weak-reference test with an observable pass and fail condition.
  • The update strategy distinguishes a restartable local host from a genuine dynamic replacement requirement.
  • ALC is treated as an assembly-loading boundary, not as protection for untrusted execution.

Deployment layout should follow execution ownership. Libraries used by host code belong to the host's output and dependency description. Libraries used only inside a plugin belong to that plugin's artifact. Exchange types belong to the host-owned contract. This rule is more reliable than organizing files by team, repository, or visual grouping because runtime resolution follows assemblies and contexts rather than a solution explorer layout.

File replacement is another lifecycle concern. A loaded assembly can keep its source file unavailable for replacement on some operating systems and loading paths. Stop and unload the plugin before replacing its artifact, or design a tested shadow-copy workflow. Do not infer replacement safety from a successful compile. The deployment smoke test must load the packaged host and plugin from the same layout that the release process creates.

There is also a complexity boundary. Separate contexts can isolate conflicting versions, but they increase diagnostics and cleanup responsibilities. A single default context is often adequate when all extensions share one dependency set and updates happen through host restarts. Choose a custom ALC only when the requirement is concrete: plugin discovery, dependency isolation, or unloadable replacement.

Official evidence and final recommendation

Microsoft's ALC concept documentation, official plugin tutorial, and AssemblyDependencyResolver API reference were all opened successfully over HTTPS. Together they support the runtime identity, shared contract, custom context, and dependency-resolution conclusions used in this guide.

The concept documentation explains that an ALC creates a scope for loading and resolving assemblies, that a context loads one version per simple assembly name, and that type conversion can fail across loading contexts. The official plugin tutorial demonstrates a custom context backed by AssemblyDependencyResolver and specifically warns against copying the plugin contract into plugin output. The API reference defines how the resolver maps a component's dependency information to managed and native paths.

The practical recommendation is to separate three questions. First, which types are shared and therefore must come from the host-owned contract? Second, which assemblies are plugin-owned and therefore resolved inside the plugin context? Third, what lifecycle evidence proves that replacement and unloading behave as designed? Answer each with an executable check: inspect the artifact, cast through the contract, and observe a bounded weak-reference result.

That discipline turns a DLL loader into an architecture. It gives reviewers a clear reason for every assembly location, gives operators an observable failure condition, and prevents a matching file name from being mistaken for matching runtime identity. Use the default context when it meets the requirement; when it does not, introduce a custom AssemblyLoadContext with an explicit shared boundary and a reproducible verification harness.

Advertisement