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.