Better Feature Directories
Using feature directories is a BIG improvement over the default group-by-taxa convention. We discussed how to do so in our previous post.
Veteran ASP.NET developers know, however, that having to jump between directories is a nuisance at most (the IDE does it for you). The real pain comes when some controllers grow too much and become god controllers.
And while I could probably write a JRPG plot (or an horror story) about god controllers, I’ll take the easy way out and assume you already know about them*.
If you don’t, you can find more information about them by searching for “god object”.
The Problem
After some time, some controllers may grow too big.
Given that we’ve tied controller names to view location, creating new controllers will mean that we’ll need to create a new directory structure under the Application directory.
For example, if we wanted to separate the “Password Reset” actions, and the “OpenID Connect Flow Actions” from the rest of the simpler authentication actions (e.g. login, logout) we would end with something like this:
...
Application/
Auth/
Views/
Login.cshtml
AuthController.cs
Billing/
[...]
Check/
[...]
Notification/
[...]
OpenId/
OpenIdController.cs
PasswordReset/
Views/
Begin.cshtml
MailSent.cshtml
Reset.cshtml
PasswordResetController.cs
[more feature folders...]
...
We’ve added two new directories to our Application directory, and the directories for OpenId and PasswordReset are nowhere close to what conceptually is their parent feature, Auth.
It goes without saying that if we partition the other features, it’ll get even more messy.
The problem, as I see it, is that we only have one (1) level of feature categorization, a directory under Application matching the controller’s name. If we want to get a little more granular than that, we’re fuc- out of luck.
Discarded Solutions
I tried the following two things, but wasn’t happy about the results.
Partial Classes
Using partial
is the obvious answer that comes to mind.
Pros:
- It’s easy, just add the
partial
keyword. - You can put related action methods in as many separate partial files as you want.
- You have complete freedom on where you place those partial files.
Cons:
- You still have a single constructor.
- Testing isn’t any easier.
- …as many separate partial files as you want.
- …complete freedom on where you place those partial files.
- Go figure.
Custom Attribute
Optionally adding an attribute CategoryAttribute
with a single string parameter to each controller
will make it easy for us to check for the value of the attribute in our IViewLocationExpander
implementation.
For example, our ResetPasswordController
with [Category("Auth")]
would have the additional search path:
/Application/Auth/ResetPassword/Views/{0}.cshtml
Which is precisely what we’re looking for.
However…
Pros:
- It solves our issue.
- It can be optionally applied.
- It only takes an Attribute definition and changing our
IViewLocationExpander
implementation a bit.
Cons:
- No controller namespaces, everything is global.
e.g.: you can’t have a List controller in both the “Billing” and “Check” categories, as per our example. You’ll have to work with BillingList and CheckList respectively. - Accessing the value of the
CategoryAttribute
requires some the use of reflection. - JetBrains Rider can’t easily find your views in the custom location.
Just for future reference, this is how FeatureDirectoriesViewExpander
looks after adding support for our custom attribute:
public class FeatureDirectoriesViewExpander : IViewLocationExpander
{
private const string CategoryKey = "Category";
public void PopulateValues(ViewLocationExpanderContext context)
{
var ctx = context.ActionContext as ControllerContext;
var attr = ctx?.ActionDescriptor
.ControllerTypeInfo
.CustomAttributes
.SingleOrDefault(x => x.AttributeType == typeof(CategoryAttribute));
if (attr == null) return;
context.Values[CategoryKey] = attr.ConstructorArguments
.Single()
.ToString();
}
public IEnumerable<string> ExpandViewLocations(
ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
var locations = viewLocations.ToList();
if (context.Values.TryGetValue(CategoryKey, out var category))
{
locations.Add("/Application/" + category.Trim('"') + "/{1}/Views/{0}.cshtml");
}
locations.Add("/Application/{1}/Views/{0}.cshtml");
locations.Add("/Application/Shared/Views/{0}.cshtml");
return locations;
}
}
Accepted Solution
Areas. It’s so simple I’ll just show how to do it, and I’ll explain later.
- Add the following custom location template to
ExpandViewLocations
:
/Application/{2}/{1}/Views/{0}.cshtml
- Add the following assembly level attribute for IDE support:
AspMvcAreaViewLocationFormat("/Application/{2}/{1}/Views/{0}.cshtml")
- Decorate controllers with
AreaAttribute
.
Pros:
- It does the same as our custom attribute solution, but…
- Controllers inside an area are namespaced. We can have controllers with the same name in different areas without issue.
- Areas are part of the framework, so tag helpers will work by default.
- Proper IDE support.
Cons:
- Remember to include the area when generating URLs.
You’ll probably forget at first. I know I did.
Reasoning
While we were writing the CategoryAttribute
we noticed many times how easy was to access the area value for use
in our custom view location templates.
After noticing we would have to write our own namespace logic for controllers, and modify the default tag helpers, we thought for a bit…
- We no longer use the ASP.NET MVC conventions.
- Areas are about Razor view locations and routing namespaces.
- The official documentation suggests using areas to group related functionality.
It’s not hard to see why we settled on using areas.
Complete classes
IViewLocationExpander
implementation:
public class FeatureDirectoriesViewExpander : IViewLocationExpander
{
public void PopulateValues(ViewLocationExpanderContext context)
{
}
public IEnumerable<string> ExpandViewLocations(
ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
var locations = viewLocations.ToList();
locations.Add("/Application/{2}/{1}/Views/{0}.cshtml"); // <--- new
locations.Add("/Application/{1}/Views/{0}.cshtml");
locations.Add("/Application/Shared/Views/{0}.cshtml");
return locations;
}
}
Remember to add the corresponding assembly-level attribute for IDE support:
[assembly: AspMvcAreaViewLocationFormat("/Application/{2}/{1}/Views/{0}.cshtml"),
AspMvcViewLocationFormat("/Application/{1}/Views/{0}.cshtml"),
AspMvcViewLocationFormat("/Application/Shared/Views/{0}.cshtml")]
namespace PulsoServer.App
{
public class Startup
{
// etc...
}
}
Summary
A creative use of the Areas feature enables us to use feature directories with two (2) levels of granularity, instead of just one, which is more than enough for most applications out there.
See also: Max Toro’s Rethinking ASP.NET MVC: Workflow per Controller.