This is a concise guide on how to implement plugin controllers and share services between the ASP.NET Web API application and the plugin.
I find that maintaining an enterprise level web API tends to result in a large monolithic application, particularly in older technologies such as Microsoft's MVC Framework. Microservices provide a nice solution to compartmentalize stand-alone services but I notice that this results in numerous discrete repos, adds complexity to automated deployment, involves IIS configurations, and if global policies are changed (security, logging, database connections, etc.), every microservice needs to be touched. Moving to ASP.NET Core, I wanted to explore using runtime plugin controller/services. The idea here is that the core (no pun intended) application handles all the common policies and updates to those policies affect all the controllers/services equally. Furthermore, there is no overhead in standing up a server/container or managing IIS configurations for the microservice as the additional controllers/services are simply added to the core application at runtime. Such an approach could be utilized in a licensing model to provide only those services that the customer pays for, or alternatively, to add new features to the web API without having to deploy the core application. Regardless of the pros and cons, the point of this article is to demonstrate how to go about implementing a proper plug-in architecture for an ASP.NET Core Web API application, so it can become another tool in the toolbox of architectural considerations.
The basic concept seems quite simple. We start with two projects:
- The reference web-api project (I'm starting with the code from my article, How I Start any .NET Core Web API Project)
- A .NET Core 3.1 library. Oddly not the easiest thing to create in Visual Studio 2019, at least the way my VS 2019 is configured.
When you download the reference project mentioned above and run it, it should provision your local IIS for a "Demo" site name and you should see:
which is nothing more than the controller responding with some text.
The library should be created as a sibling to the "Application" folder in reference project. I've found I had to do this from the command line. We'll create two projects:
- Plugin
- Interfaces (which will be used later)
Open the CLI and type in:
dotnet new classlib -n "Plugin" -lang C#
dotnet new classlib -n "Interfaces" -lang C#
You should now see the Application folder and the two folders with their projects we just created (ignore my "Article" folder). For example:
Add the projects in the Interfaces and Plugin folders to the solution in Visual Studio. When done, you should have:
Next, open the properties for these two projects and set the target framework to .NET Core 3.1:
In the Tools => Options for Visual Studio, make sure to uncheck "Only build startup projects and dependencies on Run."
The reason for this is that the plugin is not referenced by the main project and any changes won't be build unless you explicitly build them -- with this checkbox checked, making a change to a non-referenced project will result in a lot of head pounding "why am I not seeing my change!"
Add the reference to Microsoft.AspNetCore.Mvc
to the "plugin
" project.
We'll start with a simple plugin that only has a controller.
Rename the default class "Class1.cs" to "PluginController.cs" and start with something very basic:
using Microsoft.AspNetCore.Mvc;
namespace Plugin
{
[ApiController]
[Route("[controller]")]
public class PluginController : ControllerBase
{
public PluginController()
{
}
[HttpGet("Version")]
public object Version()
{
return "Plugin Controller v 1.0";
}
}
}
Here's the fun part. Add the following to the ConfigureServices
method in Startup.cs:
Assembly assembly =
Assembly.LoadFrom(@"C:\projects\PluginNetCoreDemo\Plugin\bin\Debug\netcoreapp3.1\Plugin.dll");
var part = new AssemblyPart(assembly);
services.AddControllers().PartManager.ApplicationParts.Add(part);
Yes, I've hard-coded the path - the point here is to demonstrate how the plugin controller is wired up rather than a discussion on how you want to determine the plugin list and paths. The interesting thing here is the line:
services.AddControllers().PartManager.ApplicationParts.Add(part);
Unfortunately, there is very little documentation or description of what the ApplicationPartManager
does, other than "Manages the parts and features of an MVC application." However, Googling "what is the ApplicationPartManager
", this link provides further useful description.
The code above also requires:
using Microsoft.AspNetCore.Mvc.ApplicationParts;
After building the project, you should be able to navigate to localhost/Demo/plugin/version and see:
This demonstrates that the controller endpoint has been wired up and can be accessed by the browser!
As soon as we want to do something a little more interesting, like using services defined in the plugin, life gets a little more complicated. The reason is that there's nothing in the plugin that allows for the wiring up of services -- there's no Startup
class and no ConfigureServices
implementation. Much as I tried to figure out how to do this with reflection in the main application, I hit some stumbling blocks, particularly with obtaining the MethodInfo
object for the AddSingleton
extension method. So I came up with the approach described here, which I find actually more flexible.
Remember the "Interfaces
" project created earlier? This is where we'll start using it. First, create a simple interface in that project:
using Microsoft.Extensions.DependencyInjection;
namespace Interaces
{
public interface IPlugin
{
void Initialize(IServiceCollection services);
}
}
Note that this requires adding the package Microsoft.Extensions.DependencyInjection
- make sure you use the latest 3.1.x version as we're using .NET Core 3.1!
In the Plugin
project, create a simple service:
namespace Plugin
{
public class PluginService
{
public string Test()
{
return "Tested!";
}
}
}
In the Plugin
project, create a class that implements it, initializing a service as an example:
using Microsoft.Extensions.DependencyInjection;
using Interfaces;
namespace Plugin
{
public class Plugin : IPlugin
{
public void Initialize(IServiceCollection services)
{
services.AddSingleton<PluginService>();
}
}
}
Now add the service to the controller's constructor, which will be injected:
using Microsoft.AspNetCore.Mvc;
namespace Plugin
{
[ApiController]
[Route("[controller]")]
public class PluginController : ControllerBase
{
private PluginService ps;
public PluginController(PluginService ps)
{
this.ps = ps;
}
[HttpGet("Version")]
public object Version()
{
return $"Plugin Controller v 1.0 {ps.Test()}";
}
}
}
Note that at this point, if we try to run the application, we'll see this error:
The reason is that we haven't called the Initialize
method in the main application so that plugin can register the service. We'll do this with reflection in the ConfigureServices
method:
var atypes = assembly.GetTypes();
var types = atypes.Where(t => t.GetInterface("IPlugin") != null).ToList();
var aservice = types[0];
var initMethod = aservice.GetMethod("Initialize", BindingFlags.Public | BindingFlags.Instance);
var obj = Activator.CreateInstance(aservice);
initMethod.Invoke(obj, new object[] { services });
and now we see that the controller is using the service!
The above code is rather horrid, so let's refactor it. We'll also have the application reference the Interfaces
project, so we can do this:
var atypes = assembly.GetTypes();
var pluginClass = atypes.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);
if (pluginClass != null)
{
var initMethod = pluginClass.GetMethod(nameof(IPlugin.Initialize),
BindingFlags.Public | BindingFlags.Instance);
var obj = Activator.CreateInstance(pluginClass);
initMethod.Invoke(obj, new object[] { services });
}
This is a lot cleaner, using nameof
, and we also don't care if the plugin doesn't implement a class with this interface -- maybe it doesn't have any services.
So now, we have plugins that can use their own services. It is important to note that this approach allows the plugin to initialize the service as it wishes: as a singleton, scoped, or transient service.
But what about exposing the service to the application?
This is where the interfaces become more useful. Let's refactor the service as:
using Interfaces;
namespace Plugin
{
public class PluginService : IPluginService
{
public string Test()
{
return "Tested!";
}
}
}
and define the IPluginService
as:
namespace Interfaces
{
public interface IPluginService
{
string Test();
}
}
Now let's go back to our Public
application controller and implement the dependency injection for IPluginService
:
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Interfaces;
namespace Demo.Controllers
{
[ApiController]
[Route("[controller]")]
public class Public : ControllerBase
{
private IPluginService ps;
public Public(IPluginService ps)
{
this.ps = ps;
}
[AllowAnonymous]
[HttpGet("Version")]
public object Version()
{
return new { Version = "1.00", PluginSays = ps.Test() };
}
}
}
Again, this time for the application's public/version
route, we get:
The reason is that the plugin initialized its service as the service type:
services.AddSingleton<PluginService>();
This line has to be changed now to:
services.AddSingleton<IPluginService, PluginService>();
and now we see:
But we broke the plugin:
So we also have to refactor the plugin controller to use the interface for dependency injection rather than the concrete service type:
using Microsoft.AspNetCore.Mvc;
using Interfaces;
namespace Plugin
{
[ApiController]
[Route("[controller]")]
public class PluginController : ControllerBase
{
private IPluginService ps;
public PluginController(IPluginService ps)
{
this.ps = ps;
}
[HttpGet("Version")]
public object Version()
{
return $"Plugin Controller v 1.0 {ps.Test()}";
}
}
}
Note the change to using IPluginService
. Now all is right with the world again:
Lastly, we want to test exposing an application service to the plugin. Again, the service must be initialized with an interface in the Interfaces
project so it can be shared by both the application and the plugin:
namespace Interfaces
{
public interface IApplicationService
{
string Test();
}
}
And our application service:
using Interfaces;
namespace Demo.Services
{
public class ApplicationService : IApplicationService
{
public string Test()
{
return "Application Service Tested!";
}
}
}
and it's initialization:
services.AddSingleton<IApplicationService, ApplicationService>();
Now in our plugin, will indicate that this interface should be injected:
using Microsoft.AspNetCore.Mvc;
using Interfaces;
namespace Plugin
{
[ApiController]
[Route("[controller]")]
public class PluginController : ControllerBase
{
private IPluginService ps;
private IApplicationService appSvc;
public PluginController(IPluginService ps, IApplicationService appSvc)
{
this.ps = ps;
this.appSvc = appSvc;
}
[HttpGet("Version")]
public object Version()
{
return $"Plugin Controller v 1.0 {ps.Test()} {appSvc.Test()}";
}
}
}
And we see:
One can use this same approach for plugins that only provide services. For example, let's add another project, Plugin2
, that only implements a service:
using Interfaces;
namespace Plugin2
{
public class Plugin2Service : IPlugin2Service
{
public int Add(int a, int b)
{
return a + b;
}
}
}
and:
using Microsoft.Extensions.DependencyInjection;
using Interfaces;
namespace Plugin2
{
public class Plugin2 : IPlugin
{
public void Initialize(IServiceCollection services)
{
services.AddSingleton<IPlugin2Service, Plugin2Service>();
}
}
}
and in the application's ConfigureServices
method, we'll add the hard-coded initialization for the second plugin (don't do this at home this way!):
Assembly assembly2 = Assembly.LoadFrom
(@"C:\projects\PluginNetCoreDemo\Plugin2\bin\Debug\netcoreapp3.1\Plugin2.dll");
var part2 = new AssemblyPart(assembly2);
services.AddControllers().PartManager.ApplicationParts.Add(part2);
var atypes2 = assembly2.GetTypes();
var pluginClass2 = atypes2.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);
if (pluginClass2 != null)
{
var initMethod = pluginClass2.GetMethod(nameof(IPlugin.Initialize),
BindingFlags.Public | BindingFlags.Instance);
var obj = Activator.CreateInstance(pluginClass2);
initMethod.Invoke(obj, new object[] { services });
}
I hope it's obvious that this is for demonstration purposes only and you would never hard-code the plugins in the ConfigureServices
method or copy & paste the initialization code!
And, in our first plugin:
using Microsoft.AspNetCore.Mvc;
using Interfaces;
namespace Plugin
{
[ApiController]
[Route("[controller]")]
public class PluginController : ControllerBase
{
private IPluginService ps;
private IPlugin2Service ps2;
private IApplicationService appSvc;
public PluginController
(IPluginService ps, IPlugin2Service ps2, IApplicationService appSvc)
{
this.ps = ps;
this.ps2 = ps2;
this.appSvc = appSvc;
}
[HttpGet("Version")]
public object Version()
{
return $"Plugin Controller v 1.0 {ps.Test()}
{appSvc.Test()} and 1 + 2 = {ps2.Add(1, 2)}";
}
}
}
and we see:
Demonstrating that the first plugin is using a service provided by the second plugin, all courtesy of the dependency injection provided by ASP.NET.
One approach is to specify the plugins in the appsettings.json file:
"Plugins": [
{ "Path": "<a href="file: netcoreapp3.1
Debug\\netcoreapp3.1\\Plugin.dll</a>" },
{ "Path": "C:\\projects\\PluginNetCoreDemo\\Plugin2\\bin\\Debug\\netcoreapp3.1\\Plugin2.dll" }
]
I opted to provide the full path as opposed to using the Assembly.GetExecutingAssembly().Location
as I think it's more flexible to not assume the plugin's DLL is in the application's execution location.
The AppSettings
class is modified to list the plugins:
public class AppSettings
{
public static AppSettings Settings { get; set; }
public AppSettings()
{
Settings = this;
}
public string Key1 { get; set; }
public string Key2 { get; set; }
public List<Plugin> Plugins { get; set; } = new List<Plugin>();
}
We can now implement an extension method to load the plugins and call the service initializer if one exists:
public static class ServicePluginExtension
{
public static IServiceCollection LoadPlugins(this IServiceCollection services,
AppSettings appSettings)
{
AppSettings.Settings.Plugins.ForEach(p =>
{
Assembly assembly = Assembly.LoadFrom(p.Path);
var part = new AssemblyPart(assembly);
services.AddControllersWithViews().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part));
var atypes = assembly.GetTypes();
var pluginClass = atypes.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);
if (pluginClass != null)
{
var initMethod = pluginClass.GetMethod(nameof(IPlugin.Initialize),
BindingFlags.Public | BindingFlags.Instance);
var obj = Activator.CreateInstance(pluginClass);
initMethod.Invoke(obj, new object[] { services });
}
});
return services;
}
}
And we call it in the ConfigureServices
method with:
services.LoadPlugins();
There are other ways to this as well of course.
If the only thing you need to do is load controllers, I stumbled across this implementation, which frankly, is voodoo to me as I know nothing about how the IApplicationProvider
works.
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
...
public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
Assembly assembly = Assembly.LoadFrom(p.Path);
var atypes = assembly.GetTypes();
var types = atypes.Where(t => t.BaseType == typeof(ControllerBase)).ToList();
feature.Controllers.Add(types[0].GetTypeInfo());
}
}
and is called with:
services.AddControllers().PartManager.FeatureProviders.Add
(new GenericControllerFeatureProvider());
This implementation has the drawback that it doesn't have an IServiceCollection
instance anywhere that I can find and therefore the plugin cannot be called to register its services. But if you have only controllers in your plugins (they can still reference services from your application), then this is another viable approach.
As with my other article Client to Server File/Data Streaming, I found that a concise guide on how to implement plugin controllers and share services between the application and the plugin was very much missing from the interwebs. Hopefully, this article fills in that gap.
One thing that should be noted - I haven't implemented an assembly resolver in case the plugin references DLLs that are in its own directly rather than in the application's execution location.
Ideally, one would not share services between the application and the plugin (or between plugin and plugin) because this creates a coupling via the "interfaces" library (or worse, libraries) where, if you change the implementation, then the interface has to change, and then everything needs to be rebuilt. Possible exceptions to this are services that are highly stable, perhaps database services. An intriguing idea is for the main web-api application to simply be the initialization of plugins and common services (logging, authentication, authorization, etc) -- there's a certain appeal to this and it reminds me a bit of how HAL 9000 appears to be configured in 2001: A Space Oddyssey -- poor HAL starts to degrade as modules are unplugged! However as mentioned, this approach might result in interface dependencies, unless your plugins are autonomous.
In any case, this offers an interesting alternative to the typical implementations:
- a monolithic application
- application with DLL's referenced directly (quasi-monolithic)
- microservices
I hope you find this to be another option in the toolbox of creating ASP.NET Core web APIs.
- 3rd January, 2022: Initial version
- 8th February, 2022: Updated
LoadPlugins
in the article's code above based on feedback from Colin O"Keefe. This change is not in the download.