NashTech Blog

Building a Plugin-Based Architecture in .NET

Table of Contents

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:

  1. A shared contract (an interface all plugins implement)
  2. Dependency Injection (so plugins can request services)
  3. AssemblyLoadContext (to load plugins dynamically)
  4. A simple plugin discovery pattern

Let’s build a miniature version of that.

1. The Idea in 30 Seconds

We want this flow:

  1. App starts
  2. It scans a folder like ./plugins
  3. It loads every .dll in that folder
  4. It looks for types that implement a known interface, e.g. IPlugin
  5. 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
  • MyPluginHost references PluginContracts
  • 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 PluginContracts interface 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:

  1. A shared contract
  2. Runtime assembly loading
  3. 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.

Picture of Hoc Nguyen Thai

Hoc Nguyen Thai

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top