Twitter About Home

Partitioning an ASP.NET MVC application into separate "Areas"

UPDATE: This technique is improved in a subsequent post

Published Jul 30, 2008

MonoRail (an alternative .NET MVC web app framework) has a concept of “application areas”, which is a way of grouping related controllers together. For example, you might have a “Blog” area (including a bunch of controller classes related to blogging) and a “Calendar” area (another bunch of related controllers). It’s a nice way of splitting a huge project into a smaller set of manageable ones.

If we had this feature in ASP.NET MVC, we might put controller classes into namespaces that represent area, e.g.:

  • MyApp.Controllers.Blog.HomeController
    • MyApp.Controllers.Blog.AdminController
      • MyApp.Controllers.Calendar.HomeController
        • MyApp.Controllers.Calendar.AdminController
          • MyApp.Controllers.Calendar.RssController</ul> … and also put view templates into corresponding subfolders, e.g.:

          • /Views/Blog/Home/Index.aspx

            • /Views/Blog/Home/Post.aspx
              • /Views/Blog/Admin/RecentComments.aspx
                • /Views/Calendar/Home/Index.aspx
                  • /Views/Calendar/Home/ByMonth.aspx
                    • /Views/Calendar/Admin/Permissions.aspx</ul> … etc. However, as of Preview 4, ASP.NET MVC doesn’t yet have any such concept of “areas”. Fortunately, the MVC framework is very well designed for extensibility. So, how far can we get with implementing our own notion of “areas”?

                    ####

                    A naive solution

                    Since preview 4, it’s been possible to have multiple controllers with the same name, as long as they’re in different namespaces. To tell the routing system which one to use for a given request, you can put a “namespaces” data token on each route entry, e.g.:

                    routes.Add(new NamespaceAwareRoute("blog/{controller}/{action}/{id}", new MvcRouteHandler())
                    {
                    Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }),
                    DataTokens = new RouteValueDictionary(new { namespaces = new[] { "MyApp.Controllers.Blog" } })
                    });
                     
                    routes.Add(new NamespaceAwareRoute("calendar/{controller}/{action}/{id}", new MvcRouteHandler())
                    {
                    Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }),
                    DataTokens = new RouteValueDictionary(new { namespaces = new[] { "MyApp.Controllers.Calendar" } })
                    });

                    Great! So now, requests for /Blog/* will go to the controllers in MyApp.Controllers.Blog, while requests for /Calendar/* will go to the controllers in MyApp.Controllers.Calendar. How very lovely. We’ll get to the problem with this in a moment.

                    Putting view templates into subfolders per namespace

                    Next, to implement the namespaced view folders convention (i.e. finding views at /Views/(Namespace)/Controller/ViewName.aspx), we can implement a custom IViewLocator. To do this, how about an [ActionFilter] attribute that detects when you’re about to render a view, and jumps in to alter the set of directories that are searched for the view template?

                    public class GetViewsFromNamespaceFolderAttribute : ActionFilterAttribute
                    {
                    public override void OnResultExecuting(ResultExecutingContext filterContext)
                    {
                    ViewResult result = filterContext.Result as ViewResult;
                    Controller controller = filterContext.Controller as Controller;
                    if ((result != null) && (controller != null))
                    {
                    WebFormViewEngine viewEngine = controller.ViewEngine as WebFormViewEngine;
                    if (viewEngine != null)
                    viewEngine.ViewLocator = new NamespaceAwareViewLocator(GetLastTokenFromControllerNamespace(controller));
                    }
                    }
                     
                    private string GetLastTokenFromControllerNamespace(Controller controller)
                    {
                    string[] tokens = controller.GetType().FullName.Split('.');
                    if (tokens.Length > 1)
                    return tokens[tokens.Length - 2];
                    else
                    return "";
                    }
                     
                    public class NamespaceAwareViewLocator : ViewLocator
                    {
                    public NamespaceAwareViewLocator(string ns)
                    {
                    ViewLocationFormats = new[] {
                    "~/Views/"+ns+"/{1}/{0}.aspx", "~/Views/"+ns+"/{1}/{0}.ascx",
                    "~/Views/"+ns+"/Shared/{0}.aspx", "~/Views/"+ns+"/Shared/{0}.ascx",
                    "~/Views/Shared/{0}.aspx", "~/Views/Shared/{0}.ascx"
                    };
                    MasterLocationFormats = new[] {
                    "~/Views/"+ns+"/{1}/{0}.master",
                    "~/Views/"+ns+"/Shared/{0}.master",
                    "~/Views/Shared/{0}.master"
                    };
                    }
                    }
                    }

                    With this defined, you just need to tag your controller class with the attribute,

                    namespace MyApp.Controllers.Calendar
                    {
                    [GetViewsFromNamespaceFolder]
                    public class HomeController : Controller
                    {
                    public ViewResult Index()
                    {
                    return View();
                    }
                    }
                    }

                    … and it will indeed find its view templates in /Views/Calendar/Home. So, we’re done, right?

                    Not so fast

                    The problem with having multiple controllers with the same name is that, even though ASP.NET MVC understands it, the core routing system does not. System.Web.Routing has no awareness of the “namespaces” DataToken value, so when it comes to generating outbound URLs, namespaces are simply ignored.

                    In other words, if you generate a URL with <%= Html.ActionLink(“Click me”, “MyAction”, “Home” ) %>, or <%= Url.Action(“MyAction”) %>, or return RedirectToAction(…),  the routing system pays no heed to namespaces, either those defined in the routing configuration, or the namespace of the controller you’re currently on, and it just returns the first URL that matches the given controller/action name. There’s no way to tell it to try to keep on the ‘active’ namespace, nor even to make it filter the set of acceptable route entries by namespace.

                    A few of us had a discussion about this on the ASP.NET MVC forums, and the consensus was “you’ll just have to use named routes, all the time.” Personally, I think that would be pretty lame. Giving names to routes is a bit of a crutch really, it’s not far from being a dirty hack – one of the key selling points of System.Web.Routing is that it should let you decouple the concern of routing from the concern of creating links and redirections. Named routes mangle these concerns together. (If you personally prefer to give names to your routes, (“Fluffy”, “Snowy”, and “Bunnykins” are nice names) then don’t take offence – you can do whatever you like.)

                    Solving the outbound routing problem

                    So, what’s to be done? One option is to create a custom Route subclass that’s aware of DataTokens[“namespaces”], and somehow gives priority to Route entries that match the namespace of the “current” request. That’s pretty tricky: you can only control the behavior of one Route entry at a time – you can’t control them as a group – so it’s hard to make them co-operate to change the normal rules of priority. However, if you’re willing to write some slightly odd-looking code, you can do it.

                    The idea with NamespaceAwareRoute is to pre-scan the RouteTable.Routes collection to try to find a matching route that also matches the namespace of the current request. If we find one, use that as the result. If not, revert to the usual behavior for Route entries, setting some flag that says “don’t scan any more”. Watch out, this is quite a big hunk of code:

                    private class NamespaceAwareRoute : Route
                    {
                    const string _scanKey = "__NamespaceAwareRouteHasScanned";
                     
                    public NamespaceAwareRoute(string url, IRouteHandler routeHandler) : base(url, routeHandler) { }
                    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, IRouteHandler routeHandler) : base(url, defaults, routeHandler) { }
                    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, IRouteHandler routeHandler) : base(url, defaults, constraints, routeHandler) { }
                    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler) : base(url, defaults, constraints, dataTokens, routeHandler) { }
                     
                    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
                    {
                    string controller = GetCurrentControllerTypeName(requestContext);
                    if ((controller != null) && !RouteMatchesControllerNamespace(this, controller))
                    {
                    // If we haven't already done a scan for these values, do one now
                    if (!requestContext.HttpContext.Items.Contains(_scanKey + values.GetHashCode()))
                    {
                    var vpd = GetVirtualPathWhereNamespaceMatches(requestContext, values, controller);
                    if (vpd != null)
                    return vpd;
                    requestContext.HttpContext.Items[_scanKey + values.GetHashCode()] = "scanned";
                    }
                    }
                     
                    return base.GetVirtualPath(requestContext, values);
                    }
                     
                    private VirtualPathData GetVirtualPathWhereNamespaceMatches(RequestContext requestContext, RouteValueDictionary values, string controller)
                    {
                    using (RouteTable.Routes.GetReadLock())
                    {
                    foreach (Route route in RouteTable.Routes
                              .OfType<route>()
                              .Where(r => RouteMatchesControllerNamespace(r, controller)))
                    {
                    var vpd = route.GetVirtualPath(requestContext, values);
                    if(vpd != null)
                    return vpd;
                    }
                    }
                    return null;
                    }
                     
                    private string GetCurrentControllerTypeName(RequestContext ctx)
                    {
                    var controllerContext = ctx as ControllerContext;
                    if (controllerContext == null)
                    return null;
                    else
                    return controllerContext.Controller.GetType().FullName;
                    }
                    private bool RouteMatchesControllerNamespace(Route route, string controllerTypeName)
                    {
                    if (route.DataTokens != null)
                    {
                    var namespaces = route.DataTokens["namespaces"] as IEnumerable<string>;
                    if (namespaces != null)
                    foreach (string ns in namespaces)
                    if (controllerTypeName.StartsWith(ns + "."))
                    return true;
                    }
                    return false;
                    }
                    }

                    It’s easy to use NamespaceAwareRoute. Just replace your Route objects with NamespaceAwareRoute objects. It doesn’t change the API in any way. (Unfortunately you can’t use routes.MapRoute(…) with this, because that’s hard-coded to add Route objects.) Other than that, you’ll find that outbound URL generation starts to prefer route entries that match the namespace of the “current” controller (when we can identify it).

                    So, what do you think? Is this actually useful? Would you really want to partition your app into “areas” like this? Are there any more “gotchas” I haven’t considered? Note that this code is proof-of-concept, not polished and load-tested. Use it at your own risk.

READ NEXT

Overriding IIS6 wildcard maps on individual directories

Many thanks to Duncan Smart whose comment on my previous post about deploying ASP.NET MVC applications to IIS 6 gives us a further option. It turns out that even though IIS Manager only lets you configure wildcard maps on a per-application level, IIS itself allows you to configure them on a per-directory level.

Published Jul 7, 2008