Click here to Skip to main content
15,867,771 members
Articles / Programming Languages / C# 8.0

ASP.NET Core Web API: Plugin Controllers and Services

Rate me:
Please Sign up or sign in to vote.
5.00/5 (23 votes)
3 Jan 2022CPOL10 min read 36K   675   32   8
The middle ground between monolithic applications and an explosion of microservices
This is a concise guide on how to implement plugin controllers and share services between the ASP.NET Web API application and the plugin.

Image 1

Introduction

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.

It Seems Simple

Image 2

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:

Image 3

which is nothing more than the controller responding with some text.

Create the .NET Core 3.1 Libraries

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:

Image 4

Add the Projects to the Solution

Add the projects in the Interfaces and Plugin folders to the solution in Visual Studio. When done, you should have:

Image 5

Set the Target Framework

Next, open the properties for these two projects and set the target framework to .NET Core 3.1:

Image 6

Build All Projects Regardless of Dependencies

In the Tools => Options for Visual Studio, make sure to uncheck "Only build startup projects and dependencies on Run."

Image 7

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 a Reference to Microsoft.AspNetCore.Mvc

Image 8

Add the reference to Microsoft.AspNetCore.Mvc to the "plugin" project.

The Plugin Controller

Image 9

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:

C#
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";
    }
  }
}

Load the Assembly and Tell AspNetCore to Use It

Here's the fun part. Add the following to the ConfigureServices method in Startup.cs:

C#
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:

C#
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:

C#
using Microsoft.AspNetCore.Mvc.ApplicationParts;

After building the project, you should be able to navigate to localhost/Demo/plugin/version and see:

Image 10

This demonstrates that the controller endpoint has been wired up and can be accessed by the browser!

But It Isn't Actually That Simple

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.

Initializing Services in the Plugin

Image 11

Remember the "Interfaces" project created earlier? This is where we'll start using it. First, create a simple interface in that project:

C#
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:

C#
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:

C#
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:

C#
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:

Image 12

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:

C#
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!

Image 13

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:

C#
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?

Exposing the Plugin Service to the Application

Image 14

This is where the interfaces become more useful. Let's refactor the service as:

C#
using Interfaces;

namespace Plugin
{
  public class PluginService : IPluginService
  {
    public string Test()
    {
      return "Tested!";
    }
  }
}

and define the IPluginService as:

C#
namespace Interfaces
{
  public interface IPluginService
  {
    string Test();
  }
}

Now let's go back to our Public application controller and implement the dependency injection for IPluginService:

C#
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:

Image 15

The reason is that the plugin initialized its service as the service type:

C#
services.AddSingleton<PluginService>();

This line has to be changed now to:

C#
services.AddSingleton<IPluginService, PluginService>();

and now we see:

Image 16

But we broke the plugin:

Image 17

So we also have to refactor the plugin controller to use the interface for dependency injection rather than the concrete service type:

C#
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:

Image 18

Exposing an Application Service to the Plugin

Image 19

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:

C#
namespace Interfaces
{
  public interface IApplicationService
  {
    string Test();
  }
}

And our application service:

C#
using Interfaces;

namespace Demo.Services
{
  public class ApplicationService : IApplicationService
  {
    public string Test()
    {
      return "Application Service Tested!";
    }
  }
}

and it's initialization:

C#
services.AddSingleton<IApplicationService, ApplicationService>();

Now in our plugin, will indicate that this interface should be injected:

C#
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:

Image 20

Plugins that Reference Other Plugin Services

Image 21

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:

C#
using Interfaces;

namespace Plugin2
{
  public class Plugin2Service : IPlugin2Service
  {
    public int Add(int a, int b)
    {
      return a + b;
    }
  }
}

and:

C#
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!):

C#
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:

C#
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:

Image 22

Demonstrating that the first plugin is using a service provided by the second plugin, all courtesy of the dependency injection provided by ASP.NET.

A General Plugin Loader

One approach is to specify the plugins in the appsettings.json file:

C#
"Plugins": [
  { "Path": "<a href="file:///C://projects//PluginNetCoreDemo//Plugin//bin//Debug//
              netcoreapp3.1//Plugin.dll">C:\\projects\\PluginNetCoreDemo\\Plugin\\bin\\
              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:

C#
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:

C#
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.AddControllers().PartManager.ApplicationParts.Add(part);
      // Correction from Colin O'Keefe so that things like customizing the routing or API versioning works,
      // which gets ignored using the above commented out AddControllers line.
      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:

C#
services.LoadPlugins();

There are other ways to this as well of course.

An Interesting Alternative for Loading Controllers Only

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.

C#
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:

C#
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.

Conclusion

Image 23

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.

History

  • 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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionHow to set DbContext in this method? Pin
Member 788164916-Aug-22 3:36
Member 788164916-Aug-22 3:36 
GeneralMy vote of 5 Pin
Igor Ladnik9-Feb-22 7:48
professionalIgor Ladnik9-Feb-22 7:48 
Praiseget all uploaded plugins (List<IPlugin>) Pin
TasibaevDavron18-Jan-22 3:37
TasibaevDavron18-Jan-22 3:37 
GeneralRe: get all uploaded plugins (List<IPlugin>) Pin
Marc Clifton20-Jan-22 6:23
mvaMarc Clifton20-Jan-22 6:23 
QuestionVery nice article Sir Pin
Mou_kol10-Jan-22 7:14
Mou_kol10-Jan-22 7:14 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA5-Jan-22 23:17
professionalȘtefan-Mihai MOGA5-Jan-22 23:17 
PraiseBravo Pin
BLeguillou4-Jan-22 0:59
professionalBLeguillou4-Jan-22 0:59 
PraiseThanks Pin
cphv3-Jan-22 23:09
cphv3-Jan-22 23:09 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.