Designing a Plugin Architecture in .NET 10

Search for a command to run...

The AssemblyLoadContext isolation claim being scoped honestly is what makes this trustworthy, it explicitly says isolation ≠ security boundary, since plugin code still runs with the host's OS identity and memory access. A lot of plugin-architecture writeups blur that line and let readers assume more safety than the CLR loader actually provides.
Hot replacement being flagged as "a different architecture, not a small addition" is the right call. Marking a load context collectible doesn't make anything swappable while the root service provider, event subscriptions, timers, and cached reflection objects all still hold references, cooperative unloading only happens once every one of those is gone, which is a much bigger design commitment than it sounds.
The path-traversal warning on the plugin manifest loader is a small detail but the kind that's easy to skip in a "here's how plugins work" tutorial, accepting an arbitrary entry path from config or a request would turn the loader itself into an RCE vector, and validating that the resolved path stays inside the plugin root is a one-line check most examples wouldn't bother showing.
Time has the nasty habit of biting you in production when you least expect it. A timestamp that is perfectly suitable for recording when an order was received is a poor way to measure how long a reque

The Missing Half of Distributed Locks

Properties have always given C# developers a clean public API over internal state. For a simple value, an auto property keeps the implementation compact: public string DisplayName { get; set; } = stri

Preparing for the Keys That Outlive Your Algorithms

A plugin architecture allows a .NET application to acquire new capabilities without adding every implementation to the host application. A pricing platform could load a separate rating plugin for marine, aviation and property products. A document processor could add parsers for new formats. An internal platform could allow individual teams to deploy integrations without rebuilding its core. The appealing part is easy to demonstrate, find a DLL, load it and call a known interface. Production design starts beyond that point. The host and plugin need a stable contract, dependencies must resolve from the correct location, two plugins may require incompatible versions of the same library, and the application needs a deliberate policy for versioning, failure and replacement.
This article builds a plugin based rating API using .NET 10, ASP.NET Core 10 and C# 14. It uses AssemblyLoadContext and AssemblyDependencyResolver, integrates plugins with the built-in dependency injection container, and explains where process isolation is still required.
AssemblyLoadContext isn't new in .NET 10. It remains the runtime primitive designed for grouping, resolving and optionally unloading dynamically loaded assemblies. AssemblyDependencyResolver resolves a plugin's managed and native dependencies using its .deps.json file. Those are still the appropriate APIs for a .NET 10 plugin host. The example targets net10.0, uses ASP.NET Core 10's minimal hosting model and C# 14, and packages each plugin as a .NET 10 component. Targeting the current runtime is important here. Microsoft recommends that dynamically loaded plugins target a runtime such as .NET 10 rather than .NET Standard because dependency resolution relies on the plugin's .deps.json file.
Dynamic plugins also influence the deployment model. Native AOT doesn't support dynamic assembly loading, so a host that discovers DLLs at runtime cannot be published as Native AOT. Trimming is risky for the same design because the trimmer can't statically discover plugin types found through reflection. For this host, the project makes those decisions explicit:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>false</PublishAot>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>
This doesn't prevent plugins from being efficient. It simply means deployment uses the JIT based .NET 10 runtime instead of a closed world native executable.
The contract assembly is the only application assembly that both sides must understand. It should contain interfaces and small data transfer types, not the host's domain model, EF Core entities or internal services. Every type added to this boundary becomes something the host may need to support across plugin versions.
The example solution contains three projects:
QuoteHost/
QuoteHost.Contracts/
Plugins/MarineRating.Plugin/
Their compile-time dependencies flow in one direction:
The host has no project reference to MarineRating.Plugin. If it did, the plugin would become an ordinary host dependency and independent deployment would disappear. The contract defines a module used during startup and a calculator used when handling requests. The contract project references the .NET 10 dependency-injection abstractions without taking a dependency on the complete ASP.NET Core framework:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.0" />
</ItemGroup>
</Project>
using Microsoft.Extensions.DependencyInjection;
namespace QuoteHost.Contracts;
public sealed record QuoteRequest(
decimal InsuredValue,
string Territory,
int ClaimsInLastFiveYears);
public sealed record QuoteResult(
string ProductCode,
decimal Premium,
string PluginId);
public interface IRiskCalculator
{
ValueTask<QuoteResult> CalculateAsync(
QuoteRequest request,
CancellationToken stopToken);
}
public interface IRatingPlugin
{
string Id { get; }
IReadOnlySet<string> ProductCodes { get; }
void RegisterServices(IServiceCollection services);
}
IRatingPlugin is intentionally small. It lets the plugin register implementations before the application's root service provider is created. The host continues to own HTTP routing, authentication, authorisation, telemetry and error handling. Allowing plugins to map arbitrary endpoints is possible, but it gives them control over a much larger part of the host. Keeping one stable host endpoint and dispatching through IRiskCalculator produces a smaller, easier to version boundary.
The marine plugin references the contract but must not copy QuoteHost.Contracts.dll into its deployment folder. Both sides need to use the exact same loaded contract assembly. Two assemblies with the same name and types can still represent different runtime type identities when loaded into different contexts.
The plugin project targets .NET 10 and enables dynamic loading:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\..\QuoteHost.Contracts\QuoteHost.Contracts.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>
EnableDynamicLoading prepares the build output for dynamic loading and ensures the plugin's private dependencies are copied beside it. Private=false and ExcludeAssets=runtime keep the shared contract out of that output. The plugin uses Microsoft.AspNetCore.App for logging and dependency injection APIs; this is valid because the web host already references the same shared framework. A plugin can't introduce a new shared framework that the host doesn't carry.
If the contract is distributed as a NuGet package instead of a project reference, use the same principle:
<PackageReference Include="QuoteHost.Contracts" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
The plugin registers a keyed service. Keyed registrations are useful because the host can select an implementation from a product code without injecting every calculator and searching the collection on each request:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using QuoteHost.Contracts;
namespace MarineRating.Plugin;
public sealed class MarineRatingPlugin : IRatingPlugin
{
public string Id => "marine-rating";
public IReadOnlySet<string> ProductCodes { get; } =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"marine"
};
public void RegisterServices(IServiceCollection services)
{
services.AddKeyedScoped<IRiskCalculator, MarineRiskCalculator>("marine");
}
}
internal sealed class MarineRiskCalculator(
ILogger<MarineRiskCalculator> logger) : IRiskCalculator
{
public ValueTask<QuoteResult> CalculateAsync(
QuoteRequest request,
CancellationToken stopToken)
{
stopToken.ThrowIfCancellationRequested();
var territoryFactor = request.Territory.ToUpperInvariant() switch
{
"ATLANTIC" => 1.18m,
"MEDITERRANEAN" => 1.08m,
_ => 1.12m
};
var claimsFactor = 1m + (request.ClaimsInLastFiveYears * 0.04m);
var premium = decimal.Round(
request.InsuredValue * 0.0065m * territoryFactor * claimsFactor,
2,
MidpointRounding.AwayFromZero);
logger.LogInformation(
"Marine quote calculated for territory {Territory}",
request.Territory);
return ValueTask.FromResult(
new QuoteResult("marine", premium, "marine-rating"));
}
}
The rating algorithm is deliberately simple, the architectural point is that the host only sees IRatingPlugin, IRiskCalculator and the contract records.
Loading every plugin into the default context creates a collision when two plugins depend on different versions of the same package. A separate AssemblyLoadContext gives each plugin its own assembly name to assembly mapping.
AssemblyDependencyResolver then locates dependencies relative to the plugin's entry assembly and .deps.json file:
using System.Reflection;
using System.Runtime.Loader;
using QuoteHost.Contracts;
namespace QuoteHost.Plugins;
internal sealed class PluginLoadContext(string entryAssemblyPath)
: AssemblyLoadContext(
$"plugin:{Path.GetFileNameWithoutExtension(entryAssemblyPath)}",
isCollectible: true)
{
private static readonly string ContractAssemblyName =
typeof(IRatingPlugin).Assembly.GetName().Name!;
private readonly AssemblyDependencyResolver _resolver =
new(entryAssemblyPath);
protected override Assembly? Load(AssemblyName assemblyName)
{
if (IsSharedAssembly(assemblyName))
{
return null;
}
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is null
? null
: LoadFromAssemblyPath(path);
}
protected override nint LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null
? nint.Zero
: LoadUnmanagedDllFromPath(path);
}
private static bool IsSharedAssembly(AssemblyName assemblyName)
{
var name = assemblyName.Name;
return string.Equals(
name,
ContractAssemblyName,
StringComparison.Ordinal)
|| name?.StartsWith(
"Microsoft.Extensions.",
StringComparison.Ordinal) is true;
}
}
Returning null for the contract and Microsoft.Extensions.* assemblies allows normal resolution to continue through the default context. The plugin therefore uses the same IRatingPlugin, IServiceCollection and logging abstractions as the host. Its private libraries still resolve inside its own context.
The boundary can be visualised as follows:
An AssemblyLoadContext provides dependency and type isolation, but it doesn't create a security boundary. Code inside the plugin still runs in the host process with the host's operating-system identity, memory and permissions.
A production host shouldn't treat every DLL in a directory as executable plugin code. Give each plugin its own directory and require a small external manifest:
plugins/
marine-rating/
plugin.json
MarineRating.Plugin.dll
MarineRating.Plugin.deps.json
MarineRiskModel.dll
aviation-rating/
plugin.json
AviationRating.Plugin.dll
AviationRating.Plugin.deps.json
An example plugin.json is:
{
"id": "marine-rating",
"version": "1.2.0",
"entryAssembly": "MarineRating.Plugin.dll",
"minimumHostContractVersion": "1.0.0"
}
The host can read and validate this file before executing anything from the plugin. The entry path must remain inside the configured plugin root; accepting an arbitrary path from configuration or an HTTP request would turn the loader into a path traversal and arbitrary code execution feature. The loader creates one context, finds exactly one concrete module and verifies that the code agrees with the external manifest:
using System.Reflection;
using System.Text.Json;
using QuoteHost.Contracts;
namespace QuoteHost.Plugins;
internal sealed record PluginManifest(
string Id,
string Version,
string EntryAssembly,
string MinimumHostContractVersion);
internal sealed record LoadedPlugin(
PluginManifest Manifest,
IRatingPlugin Module,
PluginLoadContext LoadContext);
internal static class PluginLoader
{
public static IReadOnlyList<LoadedPlugin> LoadFromDirectory(string pluginRoot)
{
var fullRoot = Path.GetFullPath(pluginRoot);
var loaded = new List<LoadedPlugin>();
if (!Directory.Exists(fullRoot))
{
return loaded;
}
foreach (var manifestPath in Directory.EnumerateFiles(
fullRoot,
"plugin.json",
SearchOption.AllDirectories))
{
var manifest = JsonSerializer.Deserialize<PluginManifest>(
File.ReadAllText(manifestPath),
new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException(
$"Invalid plugin manifest: {manifestPath}");
ValidateManifest(manifest);
var pluginDirectory = Path.GetDirectoryName(manifestPath)!;
var entryPath = Path.GetFullPath(
Path.Combine(pluginDirectory, manifest.EntryAssembly));
var relativeEntryPath = Path.GetRelativePath(fullRoot, entryPath);
if (Path.IsPathRooted(relativeEntryPath)
|| relativeEntryPath.Equals("..", StringComparison.Ordinal)
|| relativeEntryPath.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Plugin entry assembly escapes the plugin root: {entryPath}");
}
var loadContext = new PluginLoadContext(entryPath);
var assembly = loadContext.LoadFromAssemblyPath(entryPath);
var moduleTypes = assembly.ExportedTypes
.Where(type =>
!type.IsAbstract
&& !type.IsInterface
&& typeof(IRatingPlugin).IsAssignableFrom(type))
.ToArray();
if (moduleTypes.Length != 1)
{
throw new InvalidOperationException(
$"{entryPath} must expose exactly one {nameof(IRatingPlugin)}.");
}
var module = (IRatingPlugin?)Activator.CreateInstance(moduleTypes[0])
?? throw new InvalidOperationException(
$"Couldn't create plugin module {moduleTypes[0].FullName}.");
if (!string.Equals(
manifest.Id,
module.Id,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Manifest ID '{manifest.Id}' doesn't match module ID '{module.Id}'.");
}
loaded.Add(new LoadedPlugin(manifest, module, loadContext));
}
EnsureUniqueIdsAndProducts(loaded);
return loaded;
}
private static void ValidateManifest(PluginManifest manifest)
{
var hostContractVersion = new Version(1, 0, 0);
var requiredVersion = Version.Parse(manifest.MinimumHostContractVersion);
if (requiredVersion > hostContractVersion)
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' requires contract {requiredVersion}, " +
$"but the host provides {hostContractVersion}.");
}
_ = Version.Parse(manifest.Version);
}
private static void EnsureUniqueIdsAndProducts(
IReadOnlyCollection<LoadedPlugin> plugins)
{
var duplicateId = plugins
.GroupBy(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateId is not null)
{
throw new InvalidOperationException(
$"Duplicate plugin ID '{duplicateId.Key}'.");
}
var duplicateProduct = plugins
.SelectMany(plugin => plugin.Module.ProductCodes.Select(
product => new { Product = product, plugin.Manifest.Id }))
.GroupBy(item => item.Product, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateProduct is not null)
{
throw new InvalidOperationException(
$"Product '{duplicateProduct.Key}' is registered by multiple plugins.");
}
}
}
This loader fails the application startup when the plugin set is inconsistent. For an internal platform where every plugin is required, that is usually preferable to starting with an unknown subset of capabilities. An optional plugin platform could quarantine invalid plugins and expose their failure state through health and administration endpoints instead.
The default ASP.NET Core service collection is mutable until builder.Build(). Load and register startup plugins before that call:
using QuoteHost.Contracts;
using QuoteHost.Plugins;
var builder = WebApplication.CreateBuilder(args);
var pluginRoot = Path.Combine(builder.Environment.ContentRootPath, "plugins");
var plugins = PluginLoader.LoadFromDirectory(pluginRoot);
foreach (var plugin in plugins)
{
plugin.Module.RegisterServices(builder.Services);
}
builder.Services.AddSingleton<IReadOnlyList<LoadedPlugin>>(plugins);
var app = builder.Build();
app.MapGet("/plugins", (IReadOnlyList<LoadedPlugin> loaded) =>
loaded.Select(plugin => new
{
plugin.Manifest.Id,
plugin.Manifest.Version,
Products = plugin.Module.ProductCodes
}));
app.MapPost(
"/quotes/{productCode}",
async Task<IResult> (
string productCode,
QuoteRequest request,
IServiceProvider services,
CancellationToken stopToken) =>
{
var calculator = services.GetKeyedService<IRiskCalculator>(
productCode.ToLowerInvariant());
if (calculator is null)
{
return Results.NotFound(new
{
Error = $"No rating plugin supports '{productCode}'."
});
}
var result = await calculator.CalculateAsync(request, stopToken);
return Results.Ok(result);
});
app.Run();
The startup sequence is predictable:
This is a startup loaded plugin system. Installing or upgrading a plugin means replacing its directory and restarting the host. In containerised deployments, the cleaner approach is normally to build an immutable application image or mount a versioned plugin bundle, deploy a new instance, verify it, and retire the old instance.
C# 14 extension blocks can make host side registration helpers more cohesive. For example, the loading operation can be expressed as an extension member on IServiceCollection:
using Microsoft.Extensions.DependencyInjection;
namespace QuoteHost.Plugins;
internal static class PluginRegistrationExtensions
{
extension(IServiceCollection services)
{
public IReadOnlyList<LoadedPlugin> AddRatingPlugins(string pluginRoot)
{
var plugins = PluginLoader.LoadFromDirectory(pluginRoot);
foreach (var plugin in plugins)
{
plugin.Module.RegisterServices(services);
}
services.AddSingleton<IReadOnlyList<LoadedPlugin>>(plugins);
return plugins;
}
}
}
The application startup then becomes:
var builder = WebApplication.CreateBuilder(args);
var pluginRoot = Path.Combine(builder.Environment.ContentRootPath, "plugins");
builder.Services.AddRatingPlugins(pluginRoot);
This is a .NET 10 and C# 14 convenience rather than a requirement for plugin loading. Keep the public plugin contract conservative even if the host uses newer language features internally. Binary compatibility is determined by the compiled contract, but a simple contract also makes it easier for plugin authors to upgrade and test.
Marking a load context as collectible doesn't automatically make startup registered services hot swappable. The root service provider contains references to plugin implementation types and factories. Endpoint delegates, loggers, timers, event handlers, running tasks and static fields can retain further references. As long as any of those remain reachable, the load context cannot be collected. True runtime replacement needs a level of indirection. Each plugin owns a child service provider, while the host stores only a small plugin handle behind the shared contract. Requests obtain a lease on the current handle. Replacement publishes a new handle atomically, stops new leases against the old one, waits for existing calls to drain, disposes the old provider and finally requests unloading.
Even then, unloading is cooperative. Calling Unload() begins the process, collection happens only when no references remain. Common leaks include host event subscriptions, background threads, timers, unfinished tasks, static references, cached reflection objects and exception instances that contain plugin stack data. For most ASP.NET Core services, rolling replacement of the whole process is simpler and more dependable than in process hot swapping. Runtime unloading is valuable in desktop tools, long lived design environments or hosts where restarting is unusually expensive, but it should be treated as a separate design rather than a small addition to startup discovery.
The plugin version and contract version describe different things. marine rating version 2.4.0 may still target contract 1.0. Conversely, a small plugin release may require a newer contract. Keep the contract package independently versioned and prefer additive changes. Adding a new optional interface is safer than adding a member to an interface every plugin already implements. For example, diagnostics could be introduced as a capability:
public interface IPluginDiagnostics
{
ValueTask<IReadOnlyDictionary<string, string>> GetStatusAsync(
CancellationToken stopToken);
}
The host checks whether a loaded module also implements IPluginDiagnostics. Existing plugins remain valid because the original IRatingPlugin interface hasn't changed. Version checks should happen before service registration. Record the plugin ID, plugin version, contract requirement and entry assembly hash in startup logs. Expose the non sensitive parts through an administration endpoint so operations can confirm exactly which capability is active in each instance.
An isolated load context prevents ordinary dependency collisions. It doesn't stop a plugin reading process memory, opening files, starting threads, terminating the process or using credentials available to the host. Microsoft explicitly warns that untrusted code cannot be safely loaded into a trusted .NET process. If plugins come from customers, partners or any source outside the application's trust and release process, run them behind an operating system or virtualisation boundary. A separate process, container, job runner or service can apply its own identity, resource limits, network policy and failure handling. Communication can then use HTTP, gRPC or a queue with a deliberately serialised contract.
The same applies when reliability isolation is important. An in process plugin can cause an unhandled exception, exhaust memory or block ThreadPool threads. A separate process costs more operationally, but it gives the host a boundary the CLR loader cannot provide.
A workable release process should build each plugin as a complete versioned directory. Never update individual files in the directory of a running instance because the host may observe a mixture of versions. Publish to a new directory, validate its manifest and checksum, then activate it through a new application deployment. At startup, validate at least the manifest schema, host contract range, entry path, duplicate plugin IDs, duplicate product codes and the presence of the .deps.json file. In a controlled enterprise environment, also verify an allow listed package source, artifact signature or deployment checksum.
Plugin telemetry should include plugin.id and plugin.version as structured properties. Those values make it possible to compare failures and latency across plugin releases. The host should also expose the loaded catalog through a protected endpoint and include mandatory plugin failures in readiness checks. Test the boundary from both directions. Contract tests verify that each plugin loads and registers against the supported host contract. Host tests load deliberately incompatible plugins, a duplicate product, a missing dependency, a newer contract requirement and two plugins using conflicting package versions. The collision case is especially important because it proves the separate load contexts are doing useful work.
A plugin architecture earns its complexity when capabilities genuinely need independent packaging and selection within the same host. It works well for product specific algorithms, import/export adapters, report renderers, command sets and trusted customer specific modules. If every plugin is built, tested and deployed with the host, ordinary modules may be enough. If plugins are untrusted or need strong resource and failure isolation, separate processes are a better boundary. If updates must happen without restarting, budget for child containers, request draining, atomic catalog replacement and cooperative unloading from the beginning.
For a typical .NET 10 web platform, startup discovery is the most balanced design. A small shared contract keeps coupling controlled, a load context per plugin prevents dependency collisions, AssemblyDependencyResolver uses each plugin's build metadata correctly, and the standard ASP.NET Core container remains responsible for service lifetimes. The result is extensible without turning every DLL in the application into an accidental public contract.