ASP.NET Core Routing from the Outside In
In this article, I will show you my conventions for attribute routing and how you can make your controllers shine.
I still run across ASP.NET Core MVC projects that use conventional routing. In this article, I will show you my conventions for attribute routing and how you can make your controllers shine.
Conventional vs. Attribute Routing
Conventional Routing
Conventional routing is based on conventions defined in Startup
and is also the original routing mechanism from ASP.NET MVC in the .NET Framework era. It’s what you get by default when generating a new .NET project.
Traditionally, .NET routing used PascalCase path segments which exactly matched the controller and method names they mapped to with a route template that looks like this:
/Controller/Method/Id?
The question mark signifies that the Id
bit is optional. This approach yields routes like these:
/Questions/Show/1
/Settings/YourTwitterData
/Gists/Mine
/Photo/Show/58469288
But being the astute developer you are, you know that URLs are case-sensitive and should be lowercase, preferably with hyphens as word separators. I don’t think I have ever seen a web application with PascalCase path segments outside of .NET.
Microsoft recommends conventional routing for MVC apps and attribute routing for APIs partly because conventional routing is not RESTful and can’t easily express complex routes.
I recommend attribute routing in all situations. Let’s dive in, and at the end, I will revisit the routes above using attribute routing to match how they look in the wild.
HTTP Method Attributes
Use HTTP method attributes such as [HttpGet]
, [HttpPost]
, and [HttpPut]
to constrain action methods to HTTP methods. While this is optional, action methods without any HTTP method constraints can be invoked via any HTTP method.
Model Binding Constraints
Consider using model binding constraint attributes to indicate where bindings should come from.
[FromRoute]
[FromQuery]
[FromHeader]
[FromBody]
[FromForm]
[FromFile]
[FromServices]
I recommend placing these elements in the order they’re listed, which roughly maps to how they appear in an HTTP request. [FromServices]
comes last, just before the CancellationToken
(for async methods).
[Route("search")]
public class SearchController : Controller
{
[HttpGet]
public IActionResult Search([FromQuery(Name = "q")] string query)
{
}
}
Controller and Method Naming
I recommend choosing controller and action names that match your URL path segments where possible.
For CRUD resources, I follow the Ruby on Rails resourceful routing conventions, as shown in the following table.
HTTP Method | Path | Controller#Action | Used for |
---|---|---|---|
GET | /photos |
Photos#Index |
display a list of all photos |
GET | /photos/new |
Photos#New |
return an HTML form for creating a new photo |
POST | /photos |
Photos#Create |
create a new photo |
GET | /photos/{id:int} |
Photos#Show |
display a specific photo |
GET | /photos/{id:int}/edit |
Photos#Edit |
return an HTML form for editing a photo |
PATCH/PUT | /photos/{id:int} |
Photos#Update |
update a specific photo |
DELETE | /photos/{id:int} |
Photos#Destroy |
delete a specific photo |
Examples
Let's take a look at some example URLs from around the web and how we would declare these routes in ASP.NET Core.
Example: Stack Overflow Question
https://stackoverflow.com/questions/1/where-oh-where-did-the-joel-data-go
[Route("questions")]
public class QuestionsController : Controller
{
[HttpGet("{id:long}/{slug}")]
public IActionResult Show(
[FromRoute] long id,
[FromRoute] string slug
)
{
}
}
Example: Twitter Settings
https://twitter.com/settings/your_twitter_data
[Route("settings")]
public class SettingsController : Controller
{
[HttpGet("your_twitter_data")]
public IActionResult YourTwitterData()
{
}
}
Example: GitHub Gists
https://gist.github.com/mine
public class GistsController : Controller
{
[HttpGet("mine")]
public IActionResult Mine()
{
}
}
While Gists run on a subdomain, they're a minor feature that fits under a single controller (or area).
Example: 500px Photos
https://500px.com/photo/58469288/end-of-day-by-steven-benitez
[Route("photo")]
public class PhotoController : Controller
{
[HttpGet("{id:long}/{slug}")]
public IActionResult Show(
[FromRoute] long id,
[FromRoute] string slug
)
{
}
}
500px chose the /photo
segment rather than /photos
– presumably, because they have different routes for browsing photos (/upcoming
, /fresh
, etc.). So the PhotoController
is singular because it is only concerned with showing individual photos rather than a collection.
Token Replacements
If you prefer, you can use token replacements in routes to derive the name of an [area]
, [controller]
, or [action]
.
[Route("[controller]")]
public class PhotoController : Controller
By default, this will use the PascalCase name, so if you do want to go this route, you should use a custom IOutboundParameterTransformer
to keep your URLs lowercase.
public class LowercaseWordParameterTransformer : IOutboundParameterTransformer
{
public string? TransformOutbound(object? value)
{
return value == null ? null : Regex.Replace(value.ToString()!, "([a-z])([A-Z])", "$1-$2")
.ToLower();
}
}
Model Naming Conventions
MVC
For MVC applications, I use the Form
suffix for form data being sent from the browser and the ViewModel
suffix for data being passed to a Razor view that is not posted back.
ContactsViewModel
ContactViewModel
CreateContactForm
UpdateContactForm
LoginForm
UserProfileViewModel
API
For API applications, I use the Request
suffix for inbound JSON representations and the Response
suffix for outbound JSON representations.
ContactsResponse
ContactResponse
CreateContactRequest
UpdateContactRequest
UserProfileResponse
Takeaway
Attribute routing provides a lot more control over the structure of your URLs and provides a uniform look to your MVC and API controllers.