In this article, we explore the concept and implementation of a plugin architecture in C# and .NET 8. We also learn about the motivations to use this architecture, as well as the advantages and disadvantages of doing so.
Let’s get started!
What Is the Plugin Architecture?
A plugin architecture is comprised of a host application and plugin applications, where the host application offers some core functionalities utilized by plugins. In addition, the developers of the host application also provide public interfaces for plugins to implement. If a plugin implements one or more of these interfaces the host application can load and run the plugin. Essentially, this architecture provides users the ability to run custom add-on assemblies, called plugins, on the host application.
A plugin architecture can offer users of an application the ability to use its feature set to extend or improve on it. A great example of this is building custom tools for an application in Microsoft Office like Word.
This is all possible by taking advantage of .NET’s ability to dynamically load assemblies during runtime, which allows the host application the ability to find these assemblies in a specified path and execute them, using reflection.
Let’s take a look at a plugin architecture example by developing a simple weather application that can run plugin weather apps.
Implement a Plugin Architecture
In this section, we are going to create a simple weather app. However, instead of creating an extensive application that can report all weather data, we provide the ability to create weather app plugins. The solution consists of three projects: the host application, the plugin interface class library, and a plugin.
The goal is to be able to load an assembly created outside of the host application project that can be executed.
Enabling the use of plugins empowers our users to tailor the application experience to their unique needs and preferences, which offers users a robust experience that places value on the flexibility of how the host application can be used.
Creating the Host Project
We begin by creating a console application that serves as our weather app user interface. This app prints out start-up text giving the user instructions on which commands are available, where each command will be a different plugin assembly from a different project in our solution.
Let’s begin by, in an empty folder, running the commands to create the host application and add it to a solution:
dotnet new console -o WeatherAppHost dotnet new sln dotnet sln add WeatherAppHost/WeatherAppHost.csproj
With this, we have a new app in our solution we can start working with. Further, we will implement assembly loading at run time.
Loading Assemblies at Runtime
Now, we must code the plugin behavior we want to provide to our users. Let’s create the class:
public class PluginLoadContext : AssemblyLoadContext { private AssemblyDependencyResolver _resolver; public PluginLoadContext(string pluginPath) { _resolver = new AssemblyDependencyResolver(pluginPath); } }
Here, we create the PluginLoadContext
class that inherits from AssemblyLoadContext
, which allows us to work with plugin assemblies at runtime.
Now in the Program
class, we can create a new method to do the assembly loading part:
static Assembly LoadPlugin(string assemblyPath) { Console.WriteLine($"Loading commands from: {assemblyPath}"); var loadContext = new PluginLoadContext(assemblyPath); return loadContext.LoadFromAssemblyName( new AssemblyName(Path.GetFileNameWithoutExtension(assemblyPath))); }
Here, we define the LoadPlugin()
method that takes an assemblyPath
as a parameter. With this, we create a new instance of our PluginLoadContext
class, providing the assemblyPath
. Finally, we call the LoadFromAssemblyName()
method, constructing an AssemblyName
object from assemblyName
, returning the assembly.
Next, we will look at defining an interface that all plugins must implement so that they may be used in our host application.
Defining the Plugin Interface
Firstly, we create a new class library that holds our plugin interface. In our case, the PluginBase
project contains the ICommand
plugin interface. We need to write an interface that properly provides our host application with everything required to run the plugin. This interface will be different for every application.
Let’s create one for our application:
public interface ICommand { string Name { get; } string Description { get; } int Invoke(); }
The ICommand
interface has three members: the Name
and Description
properties, and the Invoke()
method. The Name
and Description
members are used to display to the user when we print the weather app plugins available for use. Finally, the Invoke()
method is what allows us to run the plugin.
Creating a Plugin
Let’s start creating our plugin by creating a new class library that produces our plugin assembly:
dotnet new classlib -o TemperatureCommands
Next, let’s edit our .csproj
file for this app:
<PropertyGroup> <EnableDynamicLoading>true</EnableDynamicLoading> <PropertyGroup>
In the existing <PropertyGroup />
we enable <EnableDynamicLoading />
. This setting constructs the assembly, enabling its use as a plugin.
Further, we edit the project reference to the PluginBase
class library:
<ItemGroup> <ProjectReference Include="..\PlugInBase\PlugInBase.csproj"> <Private>false</Private> <ExcludeAssets>runtime</ExcludeAssets> </ProjectReference> </ItemGroup>
Firstly, the <Private>
setting tells the build to not make a copy of PluginBase.dll
in the output directory. Lastly, <ExcludesAssets>
works in the same way but for dependencies of the PluginBase
assembly.
Now we can implement our plugin! Let’s begin by implementing the ICommand
interface from the PluginBase
project we created in the last section:
public class TemperatureCommand : ICommand { public TemperatureCommand(); public string Name { get => "temperature"; } public string Description { get => "Displays high and low temperatures for the users location."; } public int Invoke() { Console.WriteLine("In your area, there will be high of 84F and a low of 69F."); return 0; } }
Here, we implement the Invoke()
method, which serves as the entry point to the plugin. For this article, we simply log the weather data to the console.
Next, let’s tie all these pieces together as we load all plugins and give the user the option to choose a command.
Running the Plugin From the Base Solution
Returning to the WeatherAppHost
project, let’s implement the Main()
method:
public static void Main(string[] args) { try { var binDir = Environment.CurrentDirectory; var files = Directory.GetFiles(binDir, "*.dll").ToList(); files.Remove(typeof(Program).Assembly.Location); files.Remove(Path.Combine(binDir, "PlugInBase.dll")); var commands = files.SelectMany(pluginPath => { var pluginAssembly = LoadPlugin(pluginPath); return CreateCommands(pluginAssembly); }).ToList(); Console.WriteLine("Welcome to the Weather App."); foreach (string commandName in args) { Console.WriteLine($"-- {commandName} --"); var command = commands.FirstOrDefault(c => c.Name == commandName); if (command == null) { Console.WriteLine(); Console.WriteLine("No such command is known."); return; } command.Invoke(); } Console.WriteLine("\nApplication Closing"); } catch (Exception ex) { Console.WriteLine(ex); } }
In the Main()
method, we search for all assemblies in our build directory, filter out the host app and PluginBase
assemblies, and load the remaining assemblies. From them, we get the ICommand
objects which we can call Invoke()
based on the command line argument array args
.
The CreateCommands()
method takes an Assembly
object and creates an instance of all classes that implement ICommand
:
static IEnumerable<ICommand> CreateCommands(Assembly assembly) { var count = 0; foreach (var type in assembly.GetTypes()) { if (type is not null && type.GetInterfaces().Any(intf => intf.FullName?.Contains(nameof(ICommand)) ?? false)) { var result = Activator.CreateInstance(type) as ICommand; if (result != null) { count++; yield return result; } } } if (count == 0) { var availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName)); throw new ApplicationException( $"Can't find any type which implements ICommand in {assembly} from {assembly.Location}.\n" + $"Available types: {availableTypes}"); } }
Here, we accept a plugin Assembly
object that is produced from the LoadPlugin()
method as an argument. We then iterate over all Type
objects contained in the assembly returned by the GetTypes()
method, checking if each implements the ICommand
interface. If so, we create an instance of this type as an ICommand
object. Finally, we return this ICommand
object. To sum up, this method returns ICommand
objects for all types that implement ICommand
in the given assembly.
In the end, users can start the application and choose from a list of commands presented to them. Again, remember that each of these commands corresponds to an individual plugin assembly separate from our host app.
Let’s take a look at the output of running the temperature
command:
Welcome to the Weather App. -- temperature -- In your area, there will be high of 84F and a low of 69F. Application Closing
Finally, now that we understand the general concept of a Plugin architecture and have developed a simple application, let’s discuss the pros and cons of enabling the plugin functionality in our apps.
Pros and Cons of the Plugin Architecture
Choosing a plugin architecture for an application is a great way to create loosely coupled applications that are robust and easier to collaborate on. Applications that offer plugin functionality are extensible, scalable, and modular by enabling parallel development of features. As a result, we can easily swap out smaller components with others without altering the core code. Often this can lead to a community of developers that develop plugins for the host application.
A good point to remember is that choosing to offer plugin functionality does not affect other architectural choices we can make for an app in development. Plugins are an additive choice that pair alongside other architecture patterns such as a Clean architecture or Client/Server.
Of course, there are also concerns with a plugin architecture to consider, with a big one such as security. Without limitations, a malicious actor could write a plugin to compromise the application or the user’s system, making it a potent attack vector. To mitigate this risk, the host application can limit plugins by specifying the features they can use or by implementing a permission system. Additionally, meticulous code reviews, logging, testing, and tight version control can mitigate issues in developing and maintaining a plugin-capable application.
Another challenge can be that plugins may introduce complexity in implementation and version control for the host application. Likewise, dependency management may become an issue when the host application changes functionality in a way that breaks a plugin.
Conclusion
The plugin architecture offers application extensibility and an avenue to create a community of developers that can enhance our application experience for others. It introduces its own set of complexities and that may be worthwhile to be able to reap the benefits plugins may bring.