Feature Folders Inside ASP.NET Core Areas — A Better Way to Organize Large Projects

The Problem with Areas

ASP.NET Core Areas are a solid way to split a large application into top-level sections — Admin, API, Portal, etc. At the beginning, they work great. Each area has its own controllers, views, and models, and everything is neatly separated.

But in one of my recent projects, each area started to grow — a lot. The Admin area alone had 15+ controllers, dozens of views, and shared assets that only made sense within that area. The Controllers folder became a wall of files. The Views folder was even worse — a flat list of subfolders, one per controller, with no grouping by business domain.

Areas/
  Admin/
    Controllers/
      DashboardController.cs
      UsersController.cs
      RolesController.cs
      ProductsController.cs
      CategoriesController.cs
      OrdersController.cs
      ReportsController.cs
      SettingsController.cs
      TranslationsController.cs
      ... 15 more
    Views/
      Dashboard/
      Users/
      Roles/
      Products/
      Categories/
      Orders/
      Reports/
      Settings/
      Translations/
      ... 15 more

It became hard to navigate. When I'm working on product management, I don't want to scroll past dashboard, reports, and user management files to find what I need. I wanted better structure — grouping related controllers and views together by business feature — but I still wanted to keep normal MVC routing and not break anything.

The Idea

The solution I came up with is simple: feature folders inside areas.

  • Areas are used for top-level separation (Admin, Portal, API)
  • Inside an area, each business feature has its own folder containing its controllers, views, and models together

The folder structure becomes:

Areas/
  Admin/
    Dashboard/
      Controllers/
        DashboardController.cs
      Views/
        Index.cshtml
    UserManagement/
      Controllers/
        UsersController.cs
        RolesController.cs
      Views/
        Users/
          Index.cshtml
          Edit.cshtml
        Roles/
          Index.cshtml
    ProductCatalog/
      Controllers/
        ProductsController.cs
        CategoriesController.cs
      Views/
        Products/
          Index.cshtml
          Edit.cshtml
        Categories/
          Index.cshtml
    Orders/
      Controllers/
        OrdersController.cs
      Views/
        Orders/
          Index.cshtml
          Details.cshtml

Now everything related to product management — controllers, views, view models — lives under Areas/Admin/ProductCatalog/. When I'm working on that feature, I open one folder and everything is there. No more scrolling through 30 unrelated files.

The Implementation

Technically, the implementation is very small. It takes a few classes to make ASP.NET Core's MVC pipeline aware of the feature folder convention.

1. FeatureAttribute

I modeled this after the built-in [Area] attribute. It marks a controller with a feature name so the routing and view location systems know where to look:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class FeatureAttribute : Attribute
{
    public string FeatureName { get; }

    public FeatureAttribute(string featureName)
    {
        FeatureName = featureName ?? throw new ArgumentNullException(nameof(featureName));
    }
}

Then you use it on your controllers alongside the standard [Area] attribute:

[Area("Admin")]
[Feature("ProductCatalog")]
public class ProductsController : Controller
{
    public IActionResult Index() => View();
    public IActionResult Edit(int id) => View();
}

[Area("Admin")]
[Feature("UserManagement")]
public class UsersController : Controller
{
    public IActionResult Index() => View();
    public IActionResult Edit(int id) => View();
}

2. ViewLocationExpander

This is the core piece. ASP.NET Core uses IViewLocationExpander to determine where Razor looks for view files. By default, it searches Areas/{area}/Views/{controller}/ and Areas/{area}/Views/Shared/. We need to add Areas/{area}/{feature}/Views/{controller}/ to that search path:

public class FeatureViewLocationExpander : IViewLocationExpander
{
    public void PopulateValues(ViewLocationExpanderContext context)
    {
        // Pull the feature name from route data
        context.Values["feature"] = context.ActionContext.RouteData.Values["feature"]?.ToString();
    }

    public IEnumerable<string> ExpandViewLocations(
        ViewLocationExpanderContext context,
        IEnumerable<string> viewLocations)
    {
        if (!context.Values.TryGetValue("feature", out var feature)
            || string.IsNullOrEmpty(feature))
        {
            // No feature — fall back to default view locations
            foreach (var location in viewLocations)
                yield return location;
            yield break;
        }

        var area = context.Values.ContainsKey("area") ? context.Values["area"] : null;

        if (!string.IsNullOrEmpty(area))
        {
            // Feature-specific view paths
            yield return $"/Areas/{area}/{feature}/Views/{{1}}/{{0}}.cshtml";
            yield return $"/Areas/{area}/{feature}/Views/Shared/{{0}}.cshtml";

            // Localized views (if you use IViewLocalizer)
            yield return $"/Areas/{area}/{feature}/Views/{{1}}/{{0}}.{{2}}.cshtml";
        }

        // Then fall through to default locations
        foreach (var location in viewLocations)
            yield return location;
    }
}

The {0}, {1}, and {2} placeholders are filled by the Razor view engine: {0} is the view name, {1} is the controller name, and {2} is for the culture/language suffix when using localized views.

Register it in Program.cs:

builder.Services.Configure<RazorViewEngineOptions>(options =>
{
    options.ViewLocationExpanders.Add(new FeatureViewLocationExpander());
});

3. Route Constraint (Optional)

If you add {feature} as a route parameter, you probably don't want it to match everything. A route constraint limits it to known feature names:

public class FeatureRouteConstraint : IRouteConstraint
{
    private readonly HashSet<string> _features;

    public FeatureRouteConstraint(IEnumerable<string> features)
    {
        _features = new HashSet<string>(features, StringComparer.OrdinalIgnoreCase);
    }

    public bool Match(
        HttpContext? httpContext,
        IRouter? route,
        string routeKey,
        RouteValueDictionary values,
        RouteDirection routeDirection)
    {
        if (!values.TryGetValue(routeKey, out var value) || value is not string feature)
            return false;

        return _features.Contains(feature);
    }
}

You could also scan your assemblies for [Feature] attributes on startup and build the constraint list automatically — no need to maintain it by hand.

4. Route Registration

Feature routes are registered before standard area routes, so they take priority when a feature is present:

app.MapControllerRoute(
    name: "area-feature",
    pattern: "{area}/{feature}/{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
    name: "area",
    pattern: "{area}/{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

With this setup, a request to /Admin/ProductCatalog/Products/Edit/5 routes to ProductsController.Edit(5) in the Admin area, ProductCatalog feature.

5. URL Generation with Tag Helpers

Generating URLs to feature routes works just like regular area routes — add asp-route-feature to your Tag Helpers:

<!-- Link to a feature page -->
<a asp-area="Admin"
   asp-route-feature="ProductCatalog"
   asp-controller="Products"
   asp-action="Index">
    Manage Products
</a>

<!-- Form posting to a feature action -->
<form asp-area="Admin"
      asp-route-feature="UserManagement"
      asp-controller="Users"
      asp-action="Edit"
      asp-route-id="@userId"
      method="post">

This generates clean URLs like /Admin/ProductCatalog/Products and /Admin/UserManagement/Users/Edit/5.

What This Looks Like in Practice

After applying this pattern, our Admin area went from a flat list of 15+ controllers to a clearly organized structure:

Areas/Admin/
  Dashboard/          → landing page, widgets
  UserManagement/     → users, roles, permissions
  ProductCatalog/     → products, categories, translations
  Orders/             → order listing, details, status tracking
  Reports/            → sales reports, export
  Settings/           → app configuration

Each feature folder is self-contained. When a new developer joins the team and is asked to work on "product management," they open Areas/Admin/ProductCatalog/ and everything they need is right there — controllers, views, view models, even feature-specific partial views. They don't need to understand the entire Admin area to get started.

When This Makes Sense

This pattern is useful when:

  • An area has more than 5-6 controllers and is getting hard to navigate
  • Related controllers and views belong together logically (e.g., Products + Categories = ProductCatalog)
  • You want new team members to find their way around quickly
  • You're building a modular application where features might be added or removed independently

For smaller areas with only 2-3 controllers, standard area organization works fine — don't add complexity where you don't need it.

The Code

I put the code into a Git repository and it's free to use for everyone. It's a small set of classes that you can drop into any ASP.NET Core MVC project:

GitHub: github.com/ivanzakrevskyi/AspNetCore-AreaFeatureFolders

It includes the FeatureAttribute, ViewLocationExpander, route constraint, and a sample project showing the folder structure in action. You can extend it to fit your needs — for example, adding feature-specific _ViewImports.cshtml files, or scanning for features automatically at startup.

If you're working on a large ASP.NET Core MVC project and your areas are getting unwieldy, give feature folders a try. It's a small change that makes a big difference in day-to-day development.

← Back to all posts