Building a Plugin-Based Architecture in .NET
Modern enterprise apps don’t want to re-deploy every time you add a new feature. A plugin-based architecture lets you drop a new assembly into a folder and the app “discovers” it at runtime — perfect for integrations, customer-specific logic, or feature packs.
In .NET, we can do this cleanly using:
- A shared contract (an interface all plugins implement)
- Dependency Injection (so plugins can request services)
AssemblyLoadContext(to load plugins dynamically)- A simple plugin discovery pattern
Let’s build a miniature version of that.
1. The Idea in 30 Seconds
We want this flow:
- App starts
- It scans a folder like
./plugins - It loads every
.dllin that folder - It looks for types that implement a known interface, e.g.
IPlugin - It registers or executes them
So we can ship a base app, and later just drop CustomerA.Plugin.dll into the folder — no recompilation.
2. Define a Shared Contract
This must live in a shared assembly referenced by both the host and the plugin projects.
namespace PluginContracts
{
public interface IPlugin
{
string Name { get; }
Task ExecuteAsync();
}
}
You can expand this later (e.g., with metadata, menu entries, or version info).
3. Write a Sample Plugin
Now create a class library project that references `PluginContracts`.
namespace HelloPlugin
{
public class HelloWorldPlugin : IPlugin
{
public string Name => "Hello World Plugin";
public Task ExecuteAsync()
{
Console.WriteLine("Hello from plugin!");
return Task.CompletedTask;
}
}
}
Build this and place the resulting `HelloPlugin.dll` into a folder called `plugins` next to your main app.
4. Loading Plugins Dynamically with `AssemblyLoadContext`
In .NET Core and .NET 6+, `AssemblyLoadContext` lets you load assemblies at runtime.
using System.Reflection;
using System.Runtime.Loader;
using PluginContracts;
public static class PluginLoader
{
public static IEnumerable LoadPlugins(string pluginFolder)
{
if (!Directory.Exists(pluginFolder))
yield break;
var dllFiles = Directory.GetFiles(pluginFolder, "*.dll");
foreach (var dll in dllFiles)
{
try
{
var assemblyPath = Path.GetFullPath(dll);
var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
var pluginTypes = asm
.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t)
&& !t.IsAbstract
&& !t.IsInterface);
foreach (var type in pluginTypes)
{
if (Activator.CreateInstance(type) is IPlugin plugin)
yield return plugin;
}
}
catch (Exception ex)
{
// Log error but continue loading other plugins
Console.WriteLine($"Error loading plugin from {dll}: {ex.Message}");
}
}
}
}
📝 **Notes:**
- Using `AssemblyLoadContext.Default` keeps it simple.
- For hot reload or unloadable plugins, you can use a custom `AssemblyLoadContext`.
- Always wrap plugin loading in try-catch to handle missing dependencies or corrupted assemblies gracefully.
- The check for `!t.IsInterface` prevents accidentally trying to instantiate interfaces.
---
5. Integrating with Dependency Injection (DI)
In a real app, plugins often need shared services like logging or database access.
You can instantiate them using the DI container instead of raw `Activator.CreateInstance`.
var builder = Host.CreateApplicationBuilder(args);
// register shared services
builder.Services.AddSingleton();
builder.Services.AddLogging();
var app = builder.Build();
// load plugins
var plugins = PluginLoader.LoadPlugins("plugins");
using var scope = app.Services.CreateScope();
var provider = scope.ServiceProvider;
foreach (var plugin in plugins)
{
var pluginType = plugin.GetType();
var resolved = (IPlugin)ActivatorUtilities.CreateInstance(provider, pluginType);
await resolved.ExecuteAsync();
}
await app.RunAsync();
Plugins can now use constructor injection for logging, database, or other services.
Example plugin using DI:
public class LoggingPlugin : IPlugin
{
private readonly ILogger _logger;
private readonly IMyBusinessService _service;
public LoggingPlugin(ILogger logger, IMyBusinessService service)
{
_logger = logger;
_service = service;
}
public string Name => "Logging Plugin";
public Task ExecuteAsync()
{
_logger.LogInformation("Plugin executing...");
_service.DoWork();
return Task.CompletedTask;
}
}
6. Let Plugins Register Their Own Services
Some plugins might need to add their own dependencies.
Add a secondary interface like `IPluginStartup`.
public interface IPluginStartup
{
void ConfigureServices(IServiceCollection services);
}
Then in the host:
foreach (var asm in pluginAssemblies)
{
var startupTypes = asm
.GetTypes()
.Where(t => typeof(IPluginStartup).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var st in startupTypes)
{
var startup = (IPluginStartup)Activator.CreateInstance(st)!;
startup.ConfigureServices(builder.Services);
}
}
Now each plugin can register its own dependencies safely.
7. Optional: Plugin Unloading (Advanced)
For hot-reload or memory-sensitive apps, you can use a custom load context:
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path != null ? LoadFromAssemblyPath(path) : null;
}
}
You can later call `Unload()` to unload plugin assemblies.
8. Folder Structure Example
/MyPluginHost
/MyPluginHost.csproj
/PluginContracts
PluginContracts.csproj
/plugins
HelloPlugin.dll
CustomerSpecific.Plugin.dll
MyPluginHostreferencesPluginContracts- Each plugin also references
PluginContracts - Host does not reference plugin projects
9. When to Use (and When Not To)
✅ Best for:
- Customer-specific integrations
- Third-party connectors (ERP, payment, logistics)
- Feature packs for on-prem products
- Internal tools with modular add-ons
❌ Avoid for:
- Simple CRUD apps
- Teams unfamiliar with runtime assembly loading
- Scenarios with heavy security restrictions
10. Security and Versioning
- Load plugins only from trusted sources
- Sign plugin assemblies
- Version your
PluginContractsinterface carefully - Log all plugin loads and failures
11. Alternative Approaches to Plugin Architectures
While `AssemblyLoadContext` is powerful, there are other valid approaches depending on your requirements:
A. MEF (Managed Extensibility Framework)
MEF is .NET’s built-in composition framework, great for simpler scenarios.
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
// Plugin contract
public interface IPlugin
{
string Name { get; }
void Execute();
}
// Plugin implementation
[Export(typeof(IPlugin))]
public class SamplePlugin : IPlugin
{
public string Name => "Sample Plugin";
public void Execute() => Console.WriteLine("MEF Plugin!");
}
// Host discovery
var catalog = new DirectoryCatalog("./plugins");
var container = new CompositionContainer(catalog);
[ImportMany]
public IEnumerable Plugins { get; set; }
container.ComposeParts(this);
Pros:
- Built into .NET Framework (System.ComponentModel.Composition)
- Attribute-based, less boilerplate
- Automatic discovery and composition
Cons:
- Less control over loading/unloading
- Not as actively maintained in .NET Core/5+
- Heavier than custom solutions
B. Roslyn Scripting API
For text-based plugins (C# scripts) loaded at runtime:
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
var script = @"
using System;
public class ScriptPlugin {
public void Run() => Console.WriteLine("Dynamic script!");
}
return new ScriptPlugin();
";
var options = ScriptOptions.Default
.AddReferences(typeof(Console).Assembly);
var result = await CSharpScript.EvaluateAsync(script, options);
result.Run();
Pros:
- No compilation required by plugin authors
- Can modify behavior without deploying DLLs
- Good for admin/power-user scripting
Cons:
- Performance overhead
- Security concerns with untrusted scripts
- Limited IntelliSense support for plugin authors
C. gRPC/REST Microservices as “Plugins”
Instead of loading assemblies, each plugin is a separate process/service:
// Plugin as a separate web service
public interface IPluginService
{
Task ExecuteAsync(string input);
}
// Host calls plugins via HTTP
public class PluginOrchestrator
{
private readonly HttpClient _client;
public async Task CallPluginAsync(string pluginUrl, string data)
{
var response = await _client.PostAsJsonAsync(pluginUrl, data);
return await response.Content.ReadAsStringAsync();
}
}
Pros:
- Complete process isolation
- Language-agnostic plugins
- Can scale independently
- No assembly version conflicts
Cons:
- Network latency
- More complex deployment
- Requires service orchestration
D. NuGet Package-Based Plugins
Plugins distributed as NuGet packages, resolved at build or startup:
// Install plugin packages dynamically
using NuGet.Protocol.Core.Types;
// Host can download and install packages at runtime
// Then load via AssemblyLoadContext
Pros:
- Versioning and dependency management built-in
- Package signing and verification
- Centralized distribution
Cons:
- Requires NuGet infrastructure
- More complex update mechanism
- Restart typically required
E. Scripting Engines (Lua, Python, JavaScript)
Embed a scripting engine for lightweight plugins:
// Using NLua for Lua scripts
using NLua;
var lua = new Lua();
lua.DoString(@"
function execute()
return 'Hello from Lua!'
end
");
var execute = lua["execute"] as LuaFunction;
var result = execute.Call()[0];
Pros:
- Very lightweight
- Sandboxed execution
- Non-.NET developers can write plugins
- Fast iteration (no compilation)
Cons:
- Language barrier for .NET teams
- Performance limitations
- Limited access to .NET libraries
Comparison Table
| Approach | Complexity | Isolation | Performance | Use Case |
|---|---|---|---|---|
| AssemblyLoadContext | Medium | Good | Excellent | Full .NET plugins |
| MEF | Low | Minimal | Good | Simple in-process plugins |
| Roslyn Scripting | Medium | Minimal | Fair | Admin scripting |
| Microservices | High | Excellent | Fair | Distributed systems |
| NuGet Packages | High | Good | Excellent | Versioned plugin ecosystem |
| Embedded Scripts | Low | Good | Fair | Simple customization |
Which Should You Choose?
- Need full .NET power + hot reload? → AssemblyLoadContext
- Simple attribute-based discovery? → MEF
- Need process isolation? → Microservices
- Want versioned distribution? → NuGet packages
- Non-developers writing plugins? → Scripting engines
- Admin customization only? → Roslyn scripts
Conclusion
A plugin-based architecture in .NET isn’t magic — it’s:
- A shared contract
- Runtime assembly loading
- Dependency injection for extensibility
Once you set up this pattern, your app can grow horizontally — adding features on demand, without redeploying the core.
The approach you choose depends on your specific needs: in-process vs out-of-process, compiled vs scripted, and the level of isolation required. AssemblyLoadContext offers the best balance of power and control for most .NET scenarios, but don’t hesitate to explore alternatives when they better fit your requirements.