Pluggable Architecture in ASP.NET MVC 4

Tags: MVC, Knockout, pubsub, observer, MVC, jQuery, MVC 4, Pluggable

All problems in computer science can be solved by another level of indirection. --David Wheeler

...except for the problem of too many layers of indirection. --Kevlin Henney

Wouldn't it be cool to create a module that can be "plugged" into an existing ASP.NET MVC 4 web app? I'm going to walk through just such a solution. First let me say that I'm indebted to Long Le for his approach to the problem. I used his work as a basis for my own streamlined solution.

The bedrock of software engineering is to minimize coupling and maximize cohesion. This is one of those truisms that every geek is supposed to have tatooed on his arm or something. Of course I'm not sure if that will help you with the ladies. In any case, elements are said to be tightly-coupled if a change in one forces a change in the other. An element is cohesive if its functionality hangs together. For instance a class that parses strings, formats URLs, and creates text files has low cohesion. A class that does one and only one thing (parses strings) has high cohesion. Which do you think is easier to maintain a year from now?

So our goal is to create a "core" web application that changes very rarely and a series of plugins or modules that can be added over time. As far as the end user is concerned it looks like a single web application with new features popping up now and then. So let's start with the core web app.

Core Web Application

Create a MVC 4 web app using the basic project template. Call it "Core" (how imaginative). Add a HomeController to it as well as a default Index View with the following markup:

@{
    ViewBag.Title = "Index";
}

<h2>Welcome to the Core Web App</h2>
 
@Html.ActionLink("Plugin", "Index", "Plugin")

Fire it up and you should see the home page rendered. If you were to click on the link you'd get a 404 of course. Before we create the plugin we need to do four things:

 

  1. Create an Areas folder where plugin views will live.
  2. Reference and configure Ninject for constructor injection on the plugins.
  3. Create a customized RazorViewEngine to support our plugins.
  4. Register the custom view engine.

Create Areas Folder

Create a folder called Areas in the project root. This is where all of our plugin Views (areas) will live.

Ninject

A DI tool like Ninject is very helpful to our goal of minimizing coupling. We want to be able to bind instances to interfaces in the code. Yet, the core is completely ignorant of its plugins. So we can't just do something like this in the core when the kernel is created:

Bind<IPluginRepository>().To<PluginRepository>();

We need a way of binding that uses reflection through all present (and future) plugin assemblies to get the interfaces. So add the following three nuget packages:

 

  • Ninject
  • Ninject.MVC3 
  • Ninject.Extensions.Conventions

After adding these packages the NinjectWebCommon bootstrapper is added to your App_Start folder. Go down to the RegisterServices method and add this code:

/// <summary>
/// Load your modules or register your services here!
/// </summary>
/// <param name="kernel">The kernel.</param>
private static void RegisterServices(IKernel kernel)
{
    // using System.IO;
    // using Ninject.Extensions.Conventions;
    // bind plugin assemblies in bin folder
    var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "bin");
    kernel.Bind(a => a.FromAssembliesInPath(path).SelectAllClasses().BindDefaultInterface());
}  

As you can see we're pointing to the Core's bin folder and telling Ninject to find all assemblies there and create bindings for all of the concrete classes that implement an interface. So Ninject gives us the ability to add plugins at any time in the future and at app start the bindings will automatically be mapped for us. We'll see this in action in a little bit.

Customized RazorViewEngine

Next we have to override the default view engine with our own. To the project root add this class:

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Web.Mvc;

namespace Core
{
    public class CoreRazorViewEngine : RazorViewEngine
    {
        public CoreRazorViewEngine()
        {
            AreaMasterLocationFormats = new[]
            {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/{1}/{0}.vbhtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.vbhtml"
            };

            AreaPartialViewLocationFormats = new[]
            {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/{1}/{0}.vbhtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.vbhtml"
            };

            var areaViewAndPartialViewLocationFormats = new List<string>
            {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/{1}/{0}.vbhtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.vbhtml"
            };

            var partialViewLocationFormats = new List<string>
            {
                "~/Views/{1}/{0}.cshtml",
                "~/Views/{1}/{0}.vbhtml",
                "~/Views/Shared/{0}.cshtml",
                "~/Views/Shared/{0}.vbhtml"
            };

            var masterLocationFormats = new List<string>
            {
                "~/Views/{1}/{0}.cshtml",
                "~/Views/{1}/{0}.vbhtml",
                "~/Views/Shared/{0}.cshtml",
                "~/Views/Shared/{0}.vbhtml"
            };

            var fullPluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "bin");
            foreach (var file in Directory.EnumerateFiles(fullPluginPath, "*Plugin*.dll"))
            {
                var assembly = Assembly.LoadFile(file);
                var plugin = assembly.GetName().Name;

                masterLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.cshtml");
                masterLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.vbhtml");
                masterLocationFormats.Add("~/Areas/" + plugin + "/Views/Shared/{1}/{0}.cshtml");
                masterLocationFormats.Add("~/Areas/" + plugin + "/Views/Shared/{1}/{0}.vbhtml");

                partialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.cshtml");
                partialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.vbhtml");
                partialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/Shared/{0}.cshtml");
                partialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/Shared/{0}.vbhtml");

                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.cshtml");
                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Views/{1}/{0}.vbhtml");
                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Areas/{2}/Views/{1}/{0}.cshtml");
                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Areas/{2}/Views/{1}/{0}.vbhtml");
                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Areas/{2}/Views/Shared/{0}.cshtml");
                areaViewAndPartialViewLocationFormats.Add("~/Areas/" + plugin + "/Areas/{2}/Views/Shared/{0}.vbhtml");
            }

            ViewLocationFormats = partialViewLocationFormats.ToArray();
            MasterLocationFormats = masterLocationFormats.ToArray();
            PartialViewLocationFormats = partialViewLocationFormats.ToArray();
            AreaPartialViewLocationFormats = areaViewAndPartialViewLocationFormats.ToArray();
            AreaViewLocationFormats = areaViewAndPartialViewLocationFormats.ToArray();
        }
    }
}

The Core project is complete. Now it's time to create a plugin for it.

Create the Plugin

Close the Core solution and create a new MVC 4 web application called "Plugin" using the Empty template. Add the Core project to Plugin and set it as the startup project. Go ahead and run it and click on the Plugin link. You should still get a 404 because we haven't created the plugin yet. Let's do that now. There are three things we need to do:

 

  • Create a simple interface to demonstrate how Ninject binding in the core works for us
  • Create a Plugin controller and an Index view for it
  • Create an AreaRegistration so that MVC menu routing will work

IMessageRepository

Let's keep it simple. We want to display a message on the view that comes from an interface implementation. Here's the interface, go ahead and add it to the project root:

namespace Plugin
{
    public interface IMessageRepository
    {
        string Message();
    }
}

And now here's an implementation:

namespace Plugin
{
    public class MessageRepository : IMessageRepository
    {
        public string Message()
        {
            return "Welcome to the plugin!";
        }
    }
}

Plugin Controller

Create a new PluginController class where we do constructor injection of the IMessageRepository:

namespace Plugin.Controllers
{
    public class PluginController : Controller
    {
        private readonly IMessageRepository _repository;

        public PluginController(IMessageRepository repository)
        {
            _repository = repository;
        }

        public ActionResult Index()
        {
            var message = _repository.Message();
            ViewBag.Message = message;
            return View();
        }
    }
}

Notice that we have not added Ninject to the plugin project. You could do that but what if you have dozens of plugins? The idea here is to add it just once to the core and have it work for all of the plugins in the application. Now create a default Index View by right-clicking on the word View and choosing Add View. Since we put the repository message in the ViewBag let's display it in our View. Add this snippet:

<p>@ViewBag.Message</p>

One last thing: Below the Views folder add a _ViewStart.cshtml file with the path to the Core master page:

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}

AreaRegistration

So that the routing engine picks up the new plugin area we need to add an AreaRegistration:

namespace Plugin
{
    // using System.Web.Mvc;
    public class PluginAreaRegistration : AreaRegistration
    {
        public override string AreaName
        {
            get { return "Plugin"; }
        }

        public override void RegisterArea(AreaRegistrationContext context)
        {
            context.MapRoute(
                "Plugin",
                "Plugin/{controller}/{action}/{id}",
                new { action = "Index", id = UrlParameter.Optional }
                );
        }
    }
}

Now we're code complete on our plugin. At this point we'd want to add an installer or your favorite web setup project to bundle up the plugin for later deployment. But I've noticed that none shipped with Visual Studio 2012 the other day. When WIX is ready as a nuget package for VS 2012 I'll probably use that but for now let's just keep things very simple. Edit the Plugin project properties (Alt+Enter) and make these two configuration changes:

(1) On the Build tab change the Output path to drop the Plugin.dll in the Core bin folder:

..\..\Core\Core\bin\

(2) On the Build Events tab add this post-build event command:

xcopy "$(ProjectDir)\Views" "..\Areas\$(ProjectName)\Views" /s /i /y

This will copy our plugin views over to the Core project's Areas folder that we created earlier.

Go ahead and build the solution and then run it. Now when you click on the action link you should get the message served up by the IMessageRepository implementation rather than a 404. If you get a runtime error double-check the path locations of the two build configurations above.

That's pretty much it. One of the things I've done is to create a separate WCF service called CoreService where I've fronted all of my ORM plumbing to the database with operation contracts. Any repository classes I have in my core or plugin assemblies can call a service for data. That way I can version the operation contracts and/or their implementations without needing to touch the core and its plugins. But that's a topic for another post.

9 Comments

  • Pete said

    Very informative. Do you have the source code available that you used for this article? I'm mostly interested in your CoreService implementation.

  • Dave said

    Found this very useful thanks! One question though, why are you overriding the ViewEngine with your own? The implementation I've made for this works without this. Although I suspect, an application restart would be required to "discover" new plugins. Does overriding the ViewEngine prevent this?

  • vamshi said

    hai this is vamsi. how to create home widget and how to call from one widget to another by using mvc

  • Shashank said

    Oh, great art of work. But I have also the same query as Dave said "why we need to overriding the ViewEngine ?"

  • Web Design said

    I'm really impressed with your writing skills as well as with the layout on your blog. Is this a paid theme or did you customize it yourself? Anyway keep up the nice quality writing, it's rare to see a nice blog
    like this one today.

  • Wesley Perumal said

    Hey man great article, just on a side note. I think you forgot to add your Custom View Engine to the Global.asax file to allow the area config to be instantiated.

  • Martin said

    Dave >> The CoreRazorViewEngine looks for *Plugin*.dll files in the bin folder and I think that's why. He just forgot to show how to register the custom engine :

    ViewEngines.Engines.Clear();
    ViewEngines.Engines.Add(new CoreRazorViewEngine());

    in the global.asax file ;o)

  • Geoff said

    I'm missing something... When I run the project and click the Plugin link the screen blinks, posting back, but doesn't update. It continues to say Welcome to the core web app instead of Welcome to the plugin!

    Any thoughts to what I may have missed? This is the third attempt from scratch.

Comments have been disabled for this content.