Building a Composite ASP.NET MVC Application with Pluggable Areas from External Projects and Assemblies

Update: 10/25/2012

  1. Resolved rendering issues from a Plugin, when controllers names are not unique
  2. Added support for strongly typed views for Plugin views to support Entity Framework scaffolding and/or model binding
  3. Decommissioned custom Razor View Engine, moving to convention over configuration, approach
  4. Updated (sourcecode) sample application download link with latest code base

I recently did a post on Building a Composite MVC3 Application with Ninject, this post we will achieve the same goals with a much more simplistic approach using MV3 Areas.

This provides separation of concerns and loose coupling, helping you to design and build applications using loosely coupled components that can evolve independently which can be easily and seamlessly integrated into the overall application. These types of applications are known as composite applications.

So the problem is that the MVC3 runtime only scans the current executing assembly for IControllers, classes that implement AreaRegistration (to load and register MVC Areas), etc. The key here is to get the MVC3 runtime to scan other assemblies as well so that we can have external projects/assemblies so that we can scale the application horizontally without creating one large monolithic web project which also serves lends it self well when having multiple teams working in parrallel on the same project.

So let’s start! How do we get the MVC runtime to scan external assemblies as it does witht he current executing assembly of our Web project/assembly?

Open up your AssemblyInfo.cs class and add this attribute at the very end:

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Web;
using MvcApplication8.Web;

// General Information about an assembly is controlled through the following 
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MvcApplication8.Web")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("HP")]
[assembly: AssemblyProduct("MvcApplication8.Web")]
[assembly: AssemblyCopyright("Copyright © HP 2012")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]

// Setting ComVisible to false makes the types in this assembly not visible 
// to COM components.  If you need to access a type in this assembly from 
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("cdb134c0-92a4-4fe4-a7dc-6ea3e4a88bcc")]

// Version information for an assembly consists of the following four values:
//
//      Major Version
//      Minor Version 
//      Build Number
//      Revision
//
// You can specify all the values or you can default the Revision and Build Numbers 
// by using the '*' as shown below:
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

[assembly: PreApplicationStartMethod(
  typeof(PluginAreaBootstrapper), "Init")]

Note: This is letting the runtime know that before loading the MVC3 Web project assembly please invoke the “Init” method the class named “PluginAreaBootStrapper” where we will dynamically scan for our MVC3 Plugin assemblies so that the MVC runtime can scan them as well
http://msdn.microsoft.com/en-us/library/system.web.preapplicationstartmethodattribute.aspx.

Let’s take a look at what the PluginAreaBootstrapper.Init() method is doing:

    public class PluginAreaBootstrapper
    {
        public static readonly List<Assembly> PluginAssemblies = new List<Assembly>();

        public static List<string> PluginNames()
        {
            return PluginAssemblies.Select(
                pluginAssembly => pluginAssembly.GetName().Name)
                .ToList();
        }

        public static void Init()
        {
            var fullPluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Areas");

            foreach (var file in Directory.EnumerateFiles(fullPluginPath, "*Plugin*.dll"))
                PluginAssemblies.Add(Assembly.LoadFile(file));

            PluginAssemblies.ForEach(BuildManager.AddReferencedAssembly);
        }
    }

Note: We are simply scanning the Areas directory in our main MV3 web app for any plugin assemblies that we are dropping the the [Areas] (MvcApplication8.Web/Areas) folder and adding our external Plugin assemblies with BuildManager.AddReferencedAssembly (http://msdn.microsoft.com/en-us/library/system.web.compilation.buildmanager.addreferencedassembly.aspx) before our main(host) MVC3 Web App gets started.

You can setup break points on PluginAreaBootstrapper.Init() and Global.asax.cs.ApplicationStart() and see that the PluginAreaBootstrapper.Init() (where we are loading our external Plugin assemblies) method is invoked before Global.asax.cs.ApplicationStart().

Add a custom Razor View Engine

This is so that our MVC3 application will know where to go (physical location) to retrieve views from our plugins. We are copying each of our plugins to a folder named after the plugin assembly name under the [Areas] folder from our main web app e.g. MvcApplication8.Web/Areas/MvcApplication8.Web.MyPlugin1.


    public class PluginRazorViewEngine : RazorViewEngine
    {
        public PluginRazorViewEngine()
        {
            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"
            };

            foreach (var plugin in PluginAreaBootstrapper.PluginNames())
            {
                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();
        }
    }

Note: Lines 45-77 is where we iterate through each of our plugin assemblies and register their physical location(s) of their views.

Let’s create our external VS MVC3 Web Project for our Plugin

  1. Add a MVC3 Project to our solution e.g. MVCApplication8.Web.MyPlugin1
  2. Create a controller named MyPlugin1Controller.cs

    #region
    
    using System.Web.Mvc;
    
    #endregion
    
    namespace MvcApplication8.Web.MyPlugin1.Controllers
    {
        public class MyPlugin1Controller : Controller
        {
            public ActionResult Index()
            {
                return View();
            }
        }
    }
  3. Add a View (Index.cshtml)

    
    @{
        ViewBag.Title = "Index";
        Layout = "~/Views/Shared/_Layout.cshtml";
    }
    
    <h2>Hello! This is a Razor View from MvcApplication8.Web.MyPlugin1</h2>
    
    
    
  4. Add some xcopy commands to our Plugin project build post events

    MyPlugin1 post build event

    xcopy “$(ProjectDir)\Views” “$(TargetDir)\MyPlugin1\Views\” /s /i /y
    xcopy “$(ProjectDir)\Areas” “$(TargetDir)\MyPlugin1\Areas\” /s /i /y
    xcopy “$(ProjectDir)\Content” “$(TargetDir)\MyPlugin1\Content\” /s /i /y

    MyPlugin2 post build event

    xcopy “$(ProjectDir)\Views” “$(TargetDir)\MyPlugin2\Views\” /s /i /y
    xcopy “$(ProjectDir)\Areas” “$(TargetDir)\MyPlugin2\Areas\” /s /i /y
    xcopy “$(ProjectDir)\Content” “$(TargetDir)\MyPlugin2\Content\” /s /i /y

    Note: this is to copy our plugin views under the [Areas] folder of our main web app (MvcApplication8.Web), the folder name that the plugins are copied to must match the AreaName being registered by the plugin.

  5. Set the output path for our assemblies to place the compiled assemblies from our plugins under the [Areas] folder in our main host web app e.g. MvcApplication8.Web\Areas.

Now let’s run our application and type in the url prefixed with the controller from our plugin e.g. (http://localhost:48733/MyPlugin1)

Voila.., Our controller and view from an external MVC3 project/assembly loads…!

We’ll go ahead and repeat the steps creating a second plugin just for a sanity check and run the app one more time with the url prefixed with the controller from the second plugin e.g. (http://localhost:48733/MyPlugin2)

Voila.., Our controller and view from our second plugin MVC3 project/assembly loads…!

Quick review on our Solution structure

  1. Our plugin folders that are copied to the [Area] folder of our main MVC3 web app
  2. MyPlugin1 controller from our first plugin
  3. MyPlugin2 controller from our second plugin

Registering Routes from Plugins

In order to Register Routes you have to traditionally do this in the Global.asax.cs RegisterRoutes(RouteCollection routes) method in host web application (MvcApplication.Web). There can only be one Global.asax.cs for any given site. So, how can we register Routes from our Plugins when our host web app and Plugins are now completely decoupled?! Meaning they really have no dependencies and are completely ignorant of eachother (which was our goal to begin with, which was to create a “true” decoupled MVC Pluggable architecture).

This was a great question raised by Basem, so let’s dive into the solution for this. When the MVC runtime starts up our application it scans the current executing assembly as well as our Plugin assemblies thanks to our MvcApplication8.Web.PluginAreaBootstrapper.cs implementation. Meaning it scans for implementations of IController, IDependencyResolver, IControllerFacotry, RazorViewEngines and (whats relevant to our solution at hand) any classes that implement AreaRegistration. If we decompose this implementation you will see that there are two overrides that we must implement:

  • AreaName
  • RegisterArea(AreaRegistration context)

So what we need to do here is add an implementation of AreaRegistration to each of our Plugins.

MvcApplication8.Web.MyPlugin1.MyPlugin1AreaRegistration.cs


namespace MvcApplication8.Web.MyPlugin1
{
    public class MyPlugin1AreaRegistration : AreaRegistration
    {
        public override string AreaName
        {
            get { return "MyPlugin1"; }
        }

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

MvcApplication8.Web.MyPlugin2.MyPlugin2AreaRegistration.cs


namespace MvcApplication8.Web.MyPlugin2
{
    public class MyPlugin2AreaRegistration : AreaRegistration
    {
        public override string AreaName
        {
            get { return "MyPlugin2"; }
        }

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

Now if we set up a few break points in our implementations of AreaRegistration we will notice that when the application initially starts up (you may have to close out any Casini instances or restart your site in IIS, to ensure that it is the absolute first time your app is being started) the MVC runtime will scan both of our Plugin assemblies and will read the AreaName and invoke the RegisterArea methods in them. Great, now we can add any Routes from our Plugins into the body of the RegisterArea(AreaRegistrationContext context) methods…!

Happy Coding…! 🙂

Download sample application: https://skydrive.live.com/redir?resid=949A1C97C2A17906!2600&authkey=!ANP30kAEhO6QSfs