← Back to Blog

Building a Plugin System in C#: Extensible Architectures with MEF

Have you ever used Visual Studio extensions or Photoshop plugins and wondered how they magically extend the application without touching the original code? That’s the power of plugin systems. They allow other developers-or even your future self-to add new features to an application without having to change the core logic. In this article, we’ll walk through building a plugin system in C# step by step. By the end, you’ll understand interfaces, dynamic loading, dependency injection, and even how to use Microsoft’s Managed Extensibility Framework (MEF) to make life easier.

What Exactly Is a Plugin System?

At its heart, a plugin system is made up of three building blocks: the host application, the shared contract (usually an interface), and the plugins themselves. The host application doesn’t care about what specific plugins exist-it just knows that any plugin will stick to the contract and can be executed safely.

public interface IPlugin
{
    string Name { get; }
    void Execute();
}

This interface defines the contract. Every plugin will provide a name and an Execute method. The host will call Execute without knowing the details of what happens inside. For example, one plugin could log messages, while another could calculate numbers, and the host wouldn’t need to change its code to support either.

Creating the First Plugin

Now let’s write the simplest possible plugin. This one will just say hello when executed.

public class HelloWorldPlugin : IPlugin
{
    public string Name => "Hello World Plugin";

    public void Execute()
    {
        Console.WriteLine("Hello from a plugin!");
    }
}

Notice how the class implements the IPlugin interface. The Name property gives the plugin its identity, and Execute contains the actual logic. Here it only prints a message, but it could be anything-reading a file, connecting to a database, or performing some custom operation.

Loading Plugins Dynamically

Writing plugins is nice, but the real power comes when you load them dynamically at runtime. That way, someone can drop a DLL into a folder, and your application will automatically pick it up without any recompilation.

public class PluginLoader
{
    public List<IPlugin> LoadPlugins(string pluginDirectory)
    {
        var plugins = new List<IPlugin>();
        var pluginFiles = Directory.GetFiles(pluginDirectory, "*.dll");

        foreach (var file in pluginFiles)
        {
            try
            {
                var assembly = Assembly.LoadFrom(file);
                var pluginTypes = assembly.GetTypes()
                    .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsInterface);

                foreach (var type in pluginTypes)
                {
                    var plugin = (IPlugin)Activator.CreateInstance(type);
                    plugins.Add(plugin);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Failed to load plugin {file}: {ex.Message}");
            }
        }

        return plugins;
    }
}

Here’s what’s happening: the loader scans the given directory for DLL files. Each DLL is treated as a potential plugin assembly. We use reflection to check if any types inside implement IPlugin. If they do, we create an instance using Activator.CreateInstance and add it to our plugin list. Notice that error handling is included-if one DLL fails, the rest will still load. That’s important because you don’t want a single faulty plugin breaking the whole system.

Putting the System to Use

Now that we have a loader and a plugin, let’s put everything together in a host application.

class Program
{
    static void Main()
    {
        var loader = new PluginLoader();
        var plugins = loader.LoadPlugins("plugins");

        Console.WriteLine($"Loaded {plugins.Count} plugins:");

        foreach (var plugin in plugins)
        {
            Console.WriteLine($"- {plugin.Name}");
            plugin.Execute();
        }
    }
}

The host creates a PluginLoader, points it to a folder called “plugins,” and then loads everything inside. For each plugin, it prints the name and calls Execute. Imagine how powerful this is-if someone adds a new DLL tomorrow, the host will pick it up automatically without changing a single line of this code.

Adding Metadata

Real-world plugins usually need more than just a name. They often include version numbers, descriptions, or other metadata that helps the user understand what the plugin does.

public interface IPlugin
{
    string Name { get; }
    string Description { get; }
    Version Version { get; }
    void Execute();
}

We’ve now expanded the contract to include a description and version. This allows plugins to be more descriptive and easier to manage.

public class CalculatorPlugin : IPlugin
{
    public string Name => "Calculator";
    public string Description => "Provides basic math operations";
    public Version Version => new Version(1, 0, 0);

    public void Execute()
    {
        Console.WriteLine("Calculator plugin loaded!");
    }
}

This plugin not only identifies itself as a calculator but also gives a version number. If you were building a plugin marketplace or update system, this information would be extremely useful.

Dependency Injection for Plugins

Sometimes plugins need services from the host, such as logging or configuration. To handle this, we can define a host interface that plugins can call into.

public interface IPluginHost
{
    void Log(string message);
    string GetSetting(string key);
}

public interface IPlugin
{
    string Name { get; }
    void Initialize(IPluginHost host);
    void Execute();
}

Now plugins can receive a reference to the host through Initialize. They can use host services without being tightly coupled to the actual implementation.

public class LoggingPlugin : IPlugin
{
    private IPluginHost _host;

    public string Name => "Logger";

    public void Initialize(IPluginHost host)
    {
        _host = host;
    }

    public void Execute()
    {
        _host.Log("Plugin executed successfully!");
    }
}

In this example, the LoggingPlugin doesn’t know how the host logs messages-it just trusts that the host provides a logging service. This is dependency injection at work, and it keeps the system flexible and clean.

Using MEF for Simplicity

Microsoft’s Managed Extensibility Framework (MEF) takes away much of the manual work. Instead of scanning assemblies manually, MEF discovers and composes parts for you.

[Export(typeof(IPlugin))]
public class MefPlugin : IPlugin
{
    public string Name => "MEF Plugin";

    public void Execute()
    {
        Console.WriteLine("Hello from MEF!");
    }
}

By marking the class with the [Export] attribute, MEF automatically recognizes it as an IPlugin implementation. You don’t need to write the scanning logic yourself.

public class MefPluginLoader
{
    [ImportMany]
    public IEnumerable<IPlugin> Plugins { get; set; }

    public void LoadPlugins(string pluginDirectory)
    {
        var configuration = new ContainerConfiguration()
            .WithAssemblies(Directory.GetFiles(pluginDirectory, "*.dll")
                .Select(Assembly.LoadFrom));

        using var container = configuration.CreateContainer();
        container.SatisfyImports(this);
    }
}

When LoadPlugins is called, MEF scans the assemblies and automatically fills the Plugins collection. This reduces boilerplate and lets you focus on building functionality rather than wiring things up manually.

Handling Security Concerns

Loading external code always comes with risks. What if a plugin crashes or tries to harm your system? One way to mitigate this is to load plugins in a sandboxed environment.

public class SandboxedPluginLoader
{
    public IPlugin LoadPluginSafely(string pluginPath)
    {
        var domain = AppDomain.CreateDomain("PluginDomain");

        try
        {
            var plugin = (IPlugin)domain.CreateInstanceFromAndUnwrap(
                pluginPath, "MyPlugin.PluginClass");

            return plugin;
        }
        catch
        {
            AppDomain.Unload(domain);
            throw;
        }
    }
}

Here we use AppDomains to isolate plugins. If a plugin misbehaves, you can unload the domain without affecting the host. This is particularly useful when plugins come from third parties.

A Real-World Example: Text Processing Pipeline

To see everything in action, let’s imagine we’re building a text processing pipeline where each plugin can transform text in some way.

public interface ITextProcessor
{
    string Name { get; }
    string Process(string input);
}

public class TextProcessingHost
{
    private readonly List<ITextProcessor> _processors = new();

    public void LoadProcessors(string directory)
    {
        var loader = new PluginLoader();
        // Load processors here
    }

    public string ProcessText(string input)
    {
        var result = input;
        foreach (var processor in _processors)
        {
            result = processor.Process(result);
        }
        return result;
    }
}

This design allows you to chain processors together. One plugin might convert text to uppercase, another might append a timestamp, and yet another might encrypt the result. By separating responsibilities into different plugins, the system remains modular and easy to extend.

Summary

We’ve explored how to design and implement a plugin system in C#. Starting from simple interfaces and dynamic loading, we gradually added metadata, dependency injection, MEF integration, and even sandboxing for safety. The key idea is separation of concerns: the host provides the structure, and plugins add the behavior. Once you grasp this pattern, you can apply it to many types of applications, from text processors to full-fledged extensible platforms.