August 3, 2023 - 6 min

ASP.NET MVC 5 Multi-Level Convention-Based Routing


				
				

Mario Sparica

.NET Developer

ASP.NET multi-level convention-based routing hero image

ASP.NET has convention-based routing that provides a simple and intuitive way of structuring routes in a website. But what if you want to break from that convention? How do you change the convention used by the system to organize routes to better suit your project’s needs, without the need to manually set up each route individually?


Note: this blog post concerns the .NET Framework version of ASP.NET MVC specifically.


By convention, ASP.NET has a fixed 2-level folder structure that the code expects, so that, when returning View() in controller actions, it can find the corresponding template file automatically.


<projectName>
└───Controllers
└───<controllerName>Controller.cs
└───Views
└───<controllerName>
└───<actionName>

The problem


But what if you had multiple logical sections of your application? Say you had a web application with a menu structure like this:


Home
Dashboards
└───Client Feedback
└───Last Feedback
└───Feedback Trends
└───Vehicles
└───Work Progress
Reports
└───Client Feedback
└───By Client
└───By Project
└───Vehicle Maintenance
└───Work Summary
Admin
└───Clients
└───Projects
└───Vehicles

The problem such a structure has is that, since it all has to be a flat file structure due to ASP.NET’s conventions, there will be name conflicts between pages. For instance, pages under both Dashboards > Client Feedback and Reports > Client Feedback would presumably have templates in /Views/ClientFeedback/​<actionName>.cshtml and would both be controlled by the same ClientFeedbackController.cs


Ideally, we’d have a file structure like:


<projectName>
└───Controllers
└───<sectionName>
└───<controllerName>Controller.cs
└───Views
└───<sectionName>
└───<controllerName>
└───<actionName>

Additionally, all the routes would be squashed into the same “section”, so /VehicleMaintenance would be a report, and /WorkProgress would be a dashboard, with no way to tell them apart via the link alone.


The simple and the jank


There are several ways of getting around this.


ASP.NET Areas


The most obvious solution would be Microsoft’s solution specifically addressing this: areas.


The problem with this approach is that it’s made for cases where the separate sections of your application are significantly logically distinct so as to be considered entirely separate applications.

For example, a CMS’s client end, and its administration panel backend. Or a bank’s website and the bank’s web banking client.


As such, the folder structure it requires is:


<projectName>
└───Areas
└───<areaName>
└───Controllers
└───<controllerName>Controller.cs
└───Views
└───<controllerName>
└───<actionName>

So it necessitates having several View and Controller folders, one per area, which is an overkill when just trying to structure pages within a single application.


Prefixing the controller names


You could, of course, just prefix the section name to all controller names.


So, the Dashboards > Client Feedback > Feedback Trends menu item would go to /DashboardsClientFeedback/​FeedbackTrends, and would find the template under /Views/DashboardsClientFeedback/​FeedbackTrends.cshtml.


This, in itself, is not bad. But, as more pages are added, the Views folder will just keep bloating up with no way to group items. That, and the /DashboardsClientFeedback/​FeedbackTrends link isn’t the best one possible, when /Dashboards/ClientFeedback/​FeedbackTrends would make much more sense.


ASP.NET attribute routing with explicit view


You could also structure your views however you want, use Attribute Routing to structure your controller and view routes, and point them to the appropriate view by calling the View(String) overload, passing in the full path to the view manually.


This is a workable solution that would work well on smaller projects and websites, but isn’t very scalable, since it adds a lot of overhead when adding or changing controllers (e.g. remembering to change the route and path when renaming actions), and adds clutter to the code itself.


The scalable solution


Routes


The first thing we need to do is add a new parameter to our route table entry. Let’s call this new parameter section:


routes.MapRoute(
name: "Default",
url: "{section}/{controller}/{action}/{id}",
defaults: new { section = "Home", controller = "Index", action = "Index", id = UrlParameter.Optional }
);

The defaults are up to you, but make sure to be consistent.

The above setup means that our homepage controller will be under ~/Controllers/Home/IndexController.cs and the view will be under ~/Views/Home/Index/Index.cshtml.


This should replace the old default route, not be added alongside it!


View selection


Next, we need to tell MVC how to find the appropriate views in the correct subfolder.


ASP.NET uses an object implementing the IViewEngine interface to actually search for and grab Views when rendering a page. Razor specifically uses RazorViewEngine to render views of type .cshtml and .vbhtml.

Its source code is available on this address.


As RazorViewEngine is not a sealed class, we can override it with our own custom version:


public class CustomViewEngine : RazorViewEngine { }

In its constructor, we have to define our custom route formats. This is done via six string arrays:



  • AreaViewLocationFormats

  • AreaMasterLocationFormats

  • AreaPartialViewLocationFormats

  • ViewLocationFormats

  • MasterLocationFormatS

  • PartialViewLocationFormats


For our purposes, we only need the non-area ones:


public CustomViewEngine() : base()
{
ViewLocationFormats = new string[] {
"~/Views/%1/{1}/{0}.cshtml",
"~/Views/%1/{1}/{0}.vbhtml",
"~/Views/Shared/{0}.cshtml",
"~/Views/Shared/{0}.vbhtml",
};

MasterLocationFormats = new string[] {
"~/Views/%1/{1}/{0}.cshtml",
"~/Views/%1/{1}/{0}.vbhtml",
"~/Views/Shared/{0}.cshtml",
"~/Views/Shared/{0}.vbhtml",
};

PartialViewLocationFormats = new string[] {
"~/Views/%1/{1}/{0}.cshtml",
"~/Views/%1/{1}/{0}.vbhtml",
"~/Views/Shared/{0}.cshtml",
"~/Views/Shared/{0}.vbhtml",
};
}

The format tokens {0} and {1} are necessary and later used by the ViewEngine to insert the action ({0}) and controller ({1}) into the path. For that reason, we’re using a custom token %1 to mark the section fragment of the path.


In that same class, we now need to override three functions: CreatePartialView(), CreateView() and FileExists(), in which we’ll replace our custom token with the section route parameter.


protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
{
var section = controllerContext.RouteData.Values["section"]?.ToString() ?? string.Empty;
return base.CreatePartialView(controllerContext, partialPath.Replace("%1", section));
}

protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
{
var section = controllerContext.RouteData.Values["section"]?.ToString() ?? string.Empty;
return base.CreateView(controllerContext, viewPath.Replace("%1", section), masterPath.Replace("%1", section));
}

protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
{
var section = controllerContext.RouteData.Values["section"]?.ToString() ?? string.Empty;
return base.FileExists(controllerContext, virtualPath.Replace("%1", section));
}

Next, in Global.asax.cs, in the Application_Start() function, we need to register this new ViewEngine by adding these lines to the function:


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

This will allow the MVC engine to correctly find the appropriate view when using return View();


Controller selection


The above solution will work on its own – for some cases. Unfortunately, in our example, we will still encounter an ambiguous controller exception on some routes.


For example, when we try to access /Dashboards/Vehicles, the controller that route will look for is VehiclesController. The problem is, we should have two of those, one under Dashboards, and one under Admin, each under its own section namespace.


The quick and dirty solution


We could get around this by defining multiple routes and scoping each one to a single namespace:


routes.MapRoute(
name: "Home",
url: "",
defaults: new { section = "Home", controller = "Index", action = "Index", id = UrlParameter.Optional },
namespaces: new string[] { "MVC.Controllers.Home" }
);

routes.MapRoute(
name: "Admin",
url: "Admin/{controller}/{action}/{id}",
defaults: new { section = "Admin", controller = "Index", action = "Index", id = UrlParameter.Optional },
namespaces: new string[] { "MVC.Controllers.Admin" }
);

routes.MapRoute(
name: "Dashboards",
url: "Dashboards/{controller}/{action}/{id}",
defaults: new { section = "Dashboards", controller = "Index", action = "Index", id = UrlParameter.Optional },
namespaces: new string[] { "MVC.Controllers.Dashboards" }
);

routes.MapRoute(
name: "Reports",
url: "Reports/{controller}/{action}/{id}",
defaults: new { section = "Reports", controller = "Index", action = "Index", id = UrlParameter.Optional },
namespaces: new string[] { "MVC.Controllers.Reports" }
);

This works fine, and it is perfectly serviceable for smaller projects. But it isn’t scalable, it introduces overhead when refactoring or adding routes, and has some issues when attempting to map 1- and 2-level routes (e.g. /Login).


Altering the way controllers are grabbed in the first place


If we want to keep our single simple default route, and instead change the way ASP.NET finds controllers directly, we need to provide a new IControllerFactory. We do this by extending DefaultControllerFactory.

Its source code is available on this address:


public class CustomControllerFactory : DefaultControllerFactory { }

In it, we need to override the GetControllerType(RequestContext requestContext, string controllerName) function. We also need to extract some functions from the ASP.NET source code and place it here, since they’re defined as internal.


You may be tempted to just inject the namespace into the route data, then call base.GetControllerType(requestContext, controllerName). However, by default, if no matching controller is found in the provided namespace, ASP.NET will fall back to looking inside the entire default assembly, then all assemblies. This means that something like /Test/WorkProgress will still successfully navigate to the WorkProgressController inside the Dashboards folder, even if it’s not the correct route.


This means we need to write the logic ourselves.


The class should look something like this, when all is said and done.


After adding it, we need to register it in Global.asax.cs, in the Application_Start() function:


ControllerBuilder.Current.SetControllerFactory(typeof(CustomControllerFactory));

After registering both the ViewEngine and the ControllerFactory, and rearranging the controllers and views, it should all work as expected.


Conclusion


While this example is specific to using multi-level routes, a similar approach can be used to make any alterations to ASP.NET’s convention-based routing your project may need. Once you’re providing your own ViewEngine and ControllerFactory, the doors are open to any adjustment you see fit. Use some restraint, though, document and comment your code well, since breaking from any convention carries inherent overhead when onboarding new .NET developers to the project and takes adjustment time to get used to.


The demo of this solution can be found on this link.


Give Kudos by sharing the post!

Share:

ABOUT AUTHOR

Mario Sparica

.NET Developer

Mario is a .NET developer at Q agency. He has extensive experience working with enterprise applications targeting .Net Framework and .Net Core, both on desktop platforms and the cloud.