Feature Directories in ASP.NET Core

June 02, 2020 3 minutes

A common ASP.NET MVC pattern that exists since ASP.NET MVC 1.0 is the use of convention-over-configuration directory structures:

  • Controllers go in the “Controllers” directory, and derive from the Controller base class.
  • Views go in the “Views/{controllerName}” directory. Shared views go in “Views/Shared”.
  • Models can go anywhere as they’re POCO, but the Visual Studio templates create a “Models” directory for you.

This was probably done then as to help developers to keep responsabilities separate, as the MVC pattern was new for most .NET developers back then.

11 years later, it’s apparent that the default structure isn’t ideal as it separates components based on arbitrary taxonomy, rather than grouping them based on something more useful.

For example, if we want to change the “reset password” feature, we may touch files in 3 separate directories. Given that inside Controllers, Views, and Models we’re already creating subdirectories to separate by feature or feature set, why not just remove the middleman?

Feature Directories (or folders)

We want to have our views and view/binding models together with our controller classes. There’s nothing else to it.

Feature Directories screenshot

Forunately, this is pretty simple to achieve in ASP.NET Core: we create and register an IViewLocationExpander, and (optionally) we add an assembly annotation so the IDE knows where to look for Views.

Create and register an IViewLocationExpander

This implementation will allow us to create directories inside Application for each controller we have, and a subdirectory Views to hold the view templates for that controller.

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/{1}/Views/{0}.cshtml");
        locations.Add("/Application/Shared/Views/{0}.cshtml");
        return locations;
    }
}

Registering our view location expander will add our custom paths to the default convention based ones.

services.Configure<RazorViewEngineOptions>(x =>
{
    x.ViewLocationExpanders.Add(new FeatureDirectoriesViewExpander());
});

If we want to completely remove the default paths, then we can just discard the IViewLocationExpander and use the following:

services.Configure<RazorViewEngineOptions>(x =>
{
    x.ViewLocationFormats.Clear();
    x.ViewLocationFormats.Add("/Application/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
    x.ViewLocationFormats.Add("/Application/Shared/Views/{0}" + RazorViewEngine.ViewExtension);
});

Enable IDE support for our views

Without further changes, ASP.NET Core will be able to find the views inside our feature folders without issue. However, the IDE will display errors like the following:

IDE Error view not found

It’s still looking for views on the convention based directories.

To fix this, we just need to do two things:

  1. Install the JetBrains.Annotations NuGet package.
  2. Add the assembly level attribute AspMvcViewLocationFormat for each extra location we added (2 locations in our example). I chose to add them in Startup.cs.
[assembly: AspMvcViewLocationFormat("/Application/{1}/Views/{0}.cshtml"),
           AspMvcViewLocationFormat("/Application/Shared/Views/{0}.cshtml")]

namespace PulsoServer.App
{
    public class Startup
    {
        // ...
    }
}

Additional Considerations

I’ve deliberately ignored including EcmaScript files together with the view templates for simplicity. Nowadays, you have 3 scenarios:

  • Complex UI Logic: the common answer is to use a SPA framework, like Vue or Angular.
  • Simple UI Interactions: I shamelessly include those inline in views. Don’t overengineer a single click handler for side-menus.
  • Middleground Unicorns: Use ES6 modules (or TypeScript) and process those with Webpack or Brunch. Have the wwwroot/ directory as output.

If you want to add your own raw .js files into the feature directories, you’l have to configure the Static Files Middleware so it also looks inside them.