Having discussed convention-based routing in a previous post, let us now talk about its younger, more fluid sibling: attribute routing.

Attribute routing allows us developers to define routes right next to the actions they map to. This allows for us to get very fine-grained control over what routes map to which actions.

As with convention-based routing, the routes are still evaluated against URLs, but unlike in convention-based routing, all routes are evaluated at the same time, in order to find the best match. Because of this, attribute routing wants us to be very specific when defining routes and route patterns.  

A close-up of a wall map with colored pins placed at different locations.
You go this way, and I'll go that way. Photo by Capturing the human heart. / Unsplash

Let's walk through how we can use attribute routing in our ASP.NET Core 3.0 MVC applications!

Sample Project

As with all of my code-heavy posts, this one has a sample project over on GitHub. You may want to use it to follow along here.

exceptionnotfound/RoutingAspNetCoreDemo
Contribute to exceptionnotfound/RoutingAspNetCoreDemo development by creating an account on GitHub.

Setup

In order to use attribute routing in ASP.NET Core 3.0, we need to do two things in our Startup.cs file.

First, just like with convention-based routing, we need to include the MVC controllers and views in the service layer:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();
}

Next, we need to call the new MapControllers() method inside of the UseEndpoints() method:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

The MapControllers() method enables attribute routing, and can be used in conjunction with other methods like MapControllerRoute(), MapAreaControllerRoute(), etc.

Where Are Attribute Routes Defined?

Unlike in convention-based routing, where the routes are defined in the Startup.cs file, attribute routes are defined in the controller classes as attributes on actions. For example:

[Route("Account")]
public class AccountController : Controller
{
    [Route("Index")]
    public IActionResult Index(int id = 0)
    {
        AccountVM model = new AccountVM()
        {
            ID = id
        };
        return View("Index", model);
    }
}

You may notice that the [Route] attribute has been applied to both the action and the controller; this is different from .NET Framework MVC applications, where we needed to use the now-deprecated [RoutePrefix] attribute on the controller class.

The defined routes will now match the following URL:

/account/index

Attribute Routing vs Convention-Based Routing

Attribute routing, in general, wants to have a single route per action, where each route uniquely identifies an action. Convention-based routing, by contrast, wants there to be as few routes applied to as many actions as possible.  

Action!
My right or your right? Photo by Johannes W / Unsplash

You can use both of these schemes at the same time. For example, you might have attribute routing for specialized actions (e.g. ones with multiple parameters, or unique route segments, etc.) and a convention-based route for all "normal" routes within your app (e.g. "{controller}/{action}/{id?}").

Collectively, attribute routing and convention-based routing are called just "routing" by ASP.NET Core.

Route Evaluation Order

Convention-based routing evaluates matching routes in the order they are defined. By contrast, attribute routing creates a tree of all routes and evaluates them simultaneously. What this means is that more-specific routes will always be evaluated earlier than less-specific ones.

Let's say we have the following attributes:

[Route("/post/{category}/{id}")]

and

[Route("/post/{**article}")]

In attribute routing, the first route will always be evaluated before the second, because it is more specific (and, really, that's the only logical way to do this). Therefore, most of the time you do not need to worry about the order of evaluation for your attribute routes.

However, if for some reason you want to force routes to evaluate in a specific order, you can do so by setting the order:

[Route("/post/{**article}", Order = 1)]

[Route("/post/{category}/{id}", Order = 2)]

In general, you should avoid relying on Order, and instead define appropriately-specific routes for every action you need routed.

Token Replacement

A feature of attribute routing that does not exist in convention-based routing, token replacement allows for three defined tokens ([area], [controller], and [action]) in your routes that will be replaced with appropriate values.

For example, you could decorate the controller action like so:

[Route("[controller]/[action]")]
public class TokenController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Details()
    {
        return View();
    }
}

Both the /token/index and /token/details URLs will be correctly mapped using this syntax.

[HttpGet] and [HttpPost] Shortcuts

A shortcut we can use to define routes when using attribute routing is the [HttpGet] and [HttpPost] attributes. One of the parameters to these attributes will accept a route pattern.

[HttpGet("Home/HttpGet")]
public IActionResult HttpGet()
{
    return View();
}

[HttpPost("Home/HttpPost")]
public IActionResult HttpPost()
{
    return RedirectToAction("HttpGet");
}

This is particularly useful when using "pure" attribute routing, e.g. with no convention-based routes, as it allows us to condense our code, specifying both an HTTP Verb and a route pattern in a single line.

NOTE: This also works with other HTTP verb-specifying attributes, such as [HttpPut], [HttpDelete], and [HttpOptions].

Areas

As with convention-based routing, we must handle Areas in a special way. In the case of attribute routing, that special way is to decorate the appropriate controllers with an [Area] attribute, like so:

[Area("blog")]
[Route("blog/post")]
public class PostController : Controller
{
    [Route("index")]
    public IActionResult Index()
    {
        return View();
    }

    [Route("article/{id}/{**title}")]
    public IActionResult Article(int id)
    {
        return View();
    }
}

This controller will now match the following URLs.

/blog/post/index
/blog/post/article/54/this-is-a-title
/blog/post/article/71

Ambiguous Routes

Let's say we accidentally define two routes with identical route patterns:

[Route("Account")]
public class AccountController : Controller
{
    [Route("Index")]
    public IActionResult Index(int id = 0)
    {
        AccountVM model = new AccountVM()
        {
            ID = id
        };
        return View("Index", model);
    }

    [Route("Index")]
    public IActionResult Index(string id = "0")
    {
        AccountVM model = new AccountVM()
        {
            ID = int.Parse(id)
        };
        return View("Index", model);
    }
}

C# allows this because those two actions are in fact different methods, due to their differing input parameters. But the routing system will not be able to determine which of these actions to map the URL ```/account/index``` to, and will therefore throw an AmbiguousMatchException. (Note that this is different from the similar situation when using convention-based routing, which instead throws an AmbiguousActionException).

Example Time!

Let's take a look at several of our controllers and see how some URLs might map to them.  

Sample Controllers

Here's the sample controllers, all taken from the sample project:

public class HomeController : Controller
{
    [Route("~/")]
    [Route("Home")]
    [Route("Home/Index")]
    [HttpGet]
    public IActionResult Index()
    {
        return View();
    }

    [HttpGet("Home/HttpGet")]
    public IActionResult HttpGet()
    {
        return View();
    }

    [Route("Parameters/{level}/{type}/{id}")]
    public IActionResult Parameters(int level, string type, int id)
    {
        ParametersVM model = new ParametersVM()
        {
            Level = level,
            Type = type,
            ID = id
        };
        return View(model);
    }
}
[Route("[controller]/[action]")]
public class TokenController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    public IActionResult Details()
    {
        return View();
    }
}
[Area("blog")]
[Route("blog/post")]
public class PostController : Controller
{
    [Route("index")]
    public IActionResult Index()
    {
        return View();
    }

    [Route("article/{id}/{**title}")]
    public IActionResult Article(int id)
    {
        return View();
    }
}

Example #1: App Default Route

Let's start with the simplest possible URL:

/

This will match to the HomeController.Index() action, because of the following attribute:

[Route("~/")]

The "~/" syntax means "the root of the application".

Example #2: Token Values

Let's now try this URL:

/token/details

Because of the token replacement syntax in the TokenController, this URL will match the TokenController.Details() actions.

Example #3: Incorrect URL

How about this URL?

/post/index

This will not map to any route. On first glance, it looks like it should match the PostController.Index() action, but it is missing the "blog" segment of the route pattern.

Example #4: Missing ID

Finally, let's try this URL:

/blog/post/article/this-is-a-title

Once again, on first glance this URL seems to match the PostController.Article() action. However, it is missing the required {id} route segment, and therefore this will not match any route.

Summary

Attribute routing in ASP.NET Core 3.0 allows us to define specific, customized routes for actions in our systems. Said routes are applied directly at the controller action level, allowing for high cohesion between route and action.  

Remember that:

  • We must enable routes in Startup.cs by calling services.AddControllersWithViews() and enable attribute routing by calling endpoints.MapControllers().
  • Routes are evaluated all at once to find the best match. You can specify an order using the Order parameter.
  • You can use token replacement to quickly make routes for actions in a controller, using specified tokens.
  • You can use the shortcut [HttpGet] and similar attributes for defining routes and verbs.

Don't forget to check out the sample project over on GitHub!

Happy Routing!