Skip to main content
Plugin Architectures in .NET: MEF vs. Caliburn.Micro & Dependency Isolation

Plugin Architectures in .NET: MEF vs. Caliburn.Micro & Dependency Isolation

A deep dive into building modular .NET applications using MEF and Caliburn.Micro, and how to solve dependency conflicts using AssemblyLoadContext.

  1. Posts/

Plugin Architectures in .NET: MEF vs. Caliburn.Micro & Dependency Isolation

👤

Chris Malpass

Author

Building extensible applications is a rite of passage for many .NET developers. Whether you’re building a dashboard that needs dynamic widgets or a creative tool that supports third-party effects, you eventually hit the “Plugin Problem.”

In the WPF ecosystem, two architectural patterns have dominated this space: MEF (Managed Extensibility Framework) and Caliburn.Micro’s Parent/Child Bootstrappers.

But regardless of which one you choose, you will eventually face the final boss of modularity: Dependency Conflicts.

In this post, we’ll compare these two approaches and explore how modern .NET features like AssemblyLoadContext allow us to achieve true plugin isolation.

The Contenders
#

1. MEF (Managed Extensibility Framework)
#

MEF has been the standard for .NET extensibility since .NET Framework 4.0. It relies on a declarative model using attributes.

  • Philosophy: “I have a hole here ([Import]), please fill it with a matching peg ([Export]).”
  • Pros: Deeply integrated into the ecosystem, supports metadata (lazy loading plugins without instantiating them), and handles complex dependency graphs automatically.
  • Cons: Can feel “magical” and hard to debug when composition fails.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
using System.ComponentModel.Composition;

// The Contract
public interface IPlugin { void Execute(); }

// The Plugin
[Export(typeof(IPlugin))]
public class MyCoolPlugin : IPlugin 
{
    public void Execute() => Console.WriteLine("Plugin running...");
}

// The Host
public class PluginHost 
{
    [ImportMany]
    public IEnumerable<IPlugin> Plugins { get; set; }
}

2. Caliburn.Micro (Parent/Child Bootstrappers)
#

Caliburn.Micro is a powerful MVVM framework. Its approach to modularity is less about “plugins” and more about “composition of screens.”

  • Philosophy: The application is a tree of ViewModels. A “Module” is just a child container that registers its own ViewModels and Services.
  • Pros: Extremely natural if you are already doing MVVM. It handles the UI composition (View location) automatically.
  • Cons: Tends to be more coupled to the specific MVVM framework.

In this architecture, you typically override SelectAssemblies in your Bootstrapper to tell Caliburn where to look for ViewModels.

The Problem: Dependency Hell
#

Imagine this scenario:

  1. Host App uses Newtonsoft.Json v13.0.
  2. Plugin A uses Newtonsoft.Json v13.0. (All good).
  3. Plugin B is older and references Newtonsoft.Json v9.0.

In a standard .NET application, you can only load one version of an assembly into the default context. When Plugin B tries to run, it might crash with a MethodNotFoundException because it got v13 instead of v9, or the load itself might fail.

The Solution: AssemblyLoadContext (ALC)
#

Since .NET Core (.NET 5+), we have System.Runtime.Loader.AssemblyLoadContext. This allows us to load assemblies into isolated “islands.”

By loading each plugin into its own ALC, Plugin A can have its version of JSON.NET, and Plugin B can have its own version, side-by-side in the same process.

Implementing Isolation
#

First, we need a custom context that knows how to resolve dependencies relative to the plugin’s location.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System.Reflection;
using System.Runtime.Loader;

public class PluginLoadContext : AssemblyLoadContext
{
    private AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base(isCollectible: true)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath != null)
        {
            return LoadFromAssemblyPath(assemblyPath);
        }
        return null;
    }
}

Strategy 1: MEF with ALC
#

To use this with MEF, we can’t just point a DirectoryCatalog at a folder. We need to manually load the assemblies into our ALC, and then pass those assemblies to MEF.

Note: This example uses System.ComponentModel.Composition (MEF 1). If you are using System.Composition (MEF 2 / Lightweight MEF), the container setup is slightly different (ContainerConfiguration), but the ALC principle remains the same.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
using System.ComponentModel.Composition.Hosting;

var catalogs = new AggregateCatalog();

foreach (var pluginPath in Directory.GetFiles("Plugins", "*.dll"))
{
    var alc = new PluginLoadContext(pluginPath);
    var assembly = alc.LoadFromAssemblyPath(pluginPath);
    
    // Add the assembly to MEF's catalog
    var assemblyCatalog = new AssemblyCatalog(assembly);
    catalogs.Catalogs.Add(assemblyCatalog);
}

var container = new CompositionContainer(catalogs);
// Now MEF composes using types from isolated contexts!

Strategy 2: Caliburn.Micro with ALC
#

For Caliburn.Micro, we hook into the SelectAssemblies method in our Bootstrapper.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
protected override IEnumerable<Assembly> SelectAssemblies()
{
    var assemblies = base.SelectAssemblies().ToList();

    foreach (var pluginPath in Directory.GetFiles("Plugins", "*.dll"))
    {
        var alc = new PluginLoadContext(pluginPath);
        var assembly = alc.LoadFromAssemblyPath(pluginPath);
        assemblies.Add(assembly);
    }

    return assemblies;
}

Crucial Note for Caliburn: Since Caliburn relies heavily on reflection and type equality, you must ensure that the Contract Interfaces (e.g., IShell, IModule) are shared. They must be loaded in the Default Context (the host), not loaded again inside the Plugin Context.

The AssemblyDependencyResolver usually handles this correctly: if the host already has the assembly, it won’t resolve it from the plugin folder, allowing the runtime to fall back to the default context.

Conclusion
#

  • Use MEF if your application is data-centric or service-centric, and you need complex composition rules.
  • Use Caliburn.Micro if your plugins are primarily UI screens (ViewModels/Views) and you want a seamless MVVM experience.

In either case, if you are distributing plugins that might have conflicting dependencies, wrapping them in an AssemblyLoadContext is the modern, robust way to keep your application stable.

Further Reading
#