Better Feature Directories

June 15, 2020 5 minutes

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.

  1. Add the following custom location template to ExpandViewLocations:
    /Application/{2}/{1}/Views/{0}.cshtml
  2. Add the following assembly level attribute for IDE support: AspMvcAreaViewLocationFormat("/Application/{2}/{1}/Views/{0}.cshtml")
  3. 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.