ASP.NET Core Routing: Attribute Routing, Route Templates, and Constraints
Every HTTP request your API receives needs to land somewhere. ASP.NET Core routing is the system that decides where -- it maps an incoming URL and HTTP verb to an endpoint handler, extracts parameter values from the URL along the way, and gives your application the context it needs to respond. Understanding routing deeply means fewer mysterious 404 responses, cleaner URL designs, and APIs that behave predictably under every edge case.
This guide covers routing from the ground up: how the matching engine works internally, the difference between conventional and attribute routing, how route templates and constraints control which URLs your endpoints accept, how to generate URLs programmatically, and what is new in .NET 10.
How Routing Works Internally
ASP.NET Core routing is a two-phase process: route matching and endpoint execution. In the first phase, the routing middleware compares the incoming request against a set of registered endpoints and selects the best match. In the second phase, the matched endpoint's handler is invoked -- whether that is a controller action, a minimal API handler, or a Razor Page.
In modern .NET 10 applications, the explicit UseRouting() and UseEndpoints() calls you may have seen in older codebases are optional. The minimal hosting model wires endpoint routing for common cases when you call app.MapControllers() or app.MapGet(...), though you can still configure middleware order explicitly when needed. The underlying mechanism is unchanged: an EndpointRoutingMiddleware builds a route table at startup from all registered endpoints, and an EndpointMiddleware executes the matched handler per request.
This separation between matching and execution matters. Middleware placed between routing and endpoint execution can inspect the matched endpoint without yet executing it. Rate limiters, authorization middleware, and caching middleware all exploit this -- they examine the endpoint's metadata (required roles, cache duration, rate limit policies) and act before the handler runs.
Conventional Routing: The Route Table Approach
Conventional routing was the original approach in ASP.NET MVC and remains the default for Razor Pages applications and MVC applications that render HTML views. You define a route template centrally in Program.cs using MapControllerRoute, and the framework derives the target controller and action names from URL segment positions. For example, {controller=Home}/{action=Index}/{id?} maps /products/detail/5 to ProductsController.Detail(5) automatically.
For Web APIs, conventional routing has a notable limitation: the URL structure is defined centrally, away from the controller code. When you have dozens of controllers, tracing which route pattern applies to which action becomes a chore. If you rename a controller or action, the route silently breaks. Attribute routing solves this by keeping route definitions co-located with the code they describe.
Attribute Routing: A Widely Used Approach for Web APIs
Attribute routing is commonly preferred for controller-based Web APIs. You place [Route], [HttpGet], [HttpPost], and related attributes directly on controller classes and action methods. The route definition lives exactly where the code is, making the API surface immediately readable.
The [Route("api/[controller]")] attribute on a controller class sets the base path for all actions. The [controller] token is replaced at runtime by the controller's name minus the Controller suffix. Action-level attributes like [HttpGet("{id}")] append to the controller's base route.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetAll() => Ok(Array.Empty<object>());
[HttpGet("{id:guid}")]
public IActionResult GetById(Guid id) => Ok(new { Id = id });
[HttpPost]
public IActionResult Create([FromBody] CreateOrderRequest request)
=> CreatedAtAction(nameof(GetById), new { id = Guid.NewGuid() }, request);
[HttpPut("{id:guid}")]
public IActionResult Update(Guid id, [FromBody] UpdateOrderRequest request)
=> NoContent();
[HttpDelete("{id:guid}")]
public IActionResult Delete(Guid id) => NoContent();
[HttpGet("{id:guid}/items")]
public IActionResult GetItems(Guid id) => Ok(Array.Empty<object>());
}
public record CreateOrderRequest(string CustomerId, decimal Total);
public record UpdateOrderRequest(decimal Total);
This gives you a clean RESTful URL surface: GET /api/orders, GET /api/orders/{id}, POST /api/orders, and so on. Each endpoint is self-describing and easy to understand without consulting a central route table. OpenAPI tooling like Swashbuckle (third-party, not part of the framework) and the built-in Microsoft.AspNetCore.OpenApi (supported in .NET 10, with built-in OpenAPI support first arriving in .NET 9) read these attributes automatically to generate accurate API documentation.
One important detail: you can combine a controller-level route with action-level route fragments, or you can define a completely independent route on an action by starting the route template with /. A template starting with / is absolute and ignores the controller's base route.
Route Templates: Parameters, Optional Segments, and Catch-All Routes
Route templates are the patterns you define in routing attributes. They support literal path segments, parameterized segments, optional parameters, default values, and catch-all segments -- each serving a different purpose.
A parameterized segment is wrapped in curly braces: {id}. The framework extracts the URL segment at that position and binds it to the action parameter with the matching name. Optional parameters end with ?: {id?}. If the segment is absent in the URL, the parameter receives its type's default value. Default values are specified with =: {version=1} -- if the segment is absent, the parameter is bound to 1.
Catch-all parameters use **: {**path} matches zero or more path segments, including forward slashes. This is useful for proxy endpoints, file-serving routes, or documentation path routing where the remainder of the URL should be forwarded verbatim.
[ApiController]
[Route("api/v{version:int}/[controller]")]
public class CatalogController : ControllerBase
{
// Matches: GET /api/v1/catalog/electronics/laptops/dell-xps
// categoryPath = "electronics/laptops/dell-xps"
[HttpGet("categories/{**categoryPath}")]
public IActionResult BrowseCategory(int version, string categoryPath)
=> Ok(new { Version = version, Path = categoryPath });
// Matches: GET /api/v1/catalog/items?page=2&size=25
[HttpGet("items")]
public IActionResult ListItems(
int version,
[FromQuery] int page = 1,
[FromQuery] int size = 20)
=> Ok(new { Version = version, Page = page, Size = size });
// Matches: GET /api/v1/catalog/search?q=widget&category=tools
[HttpGet("search")]
public IActionResult Search(
int version,
[FromQuery] string q,
[FromQuery] string? category = null)
=> Ok(new { Version = version, Query = q, Category = category });
}
The interplay between route parameters and query string parameters is an important design decision. Route parameters are typically used for resource identifiers -- the "noun" in your URL structure. Query parameters are for filtering, sorting, pagination, and other modifications -- the "adjectives". Keeping this distinction clean makes your API intuitive for consumers. When you need to process collections of results with filtering, LINQ in C# provides the tools to build clean, composable query logic in your service layer.
Route Constraints: Type Safety in Your URLs
Route constraints restrict what values a route parameter can match. They prevent the routing engine from selecting a route when a parameter value does not satisfy the constraint -- falling through to the next candidate route rather than matching with an invalid value.
This is the key insight about constraints: they participate in route matching, not in validation. A failed constraint means "this route does not apply to this request", which leads to a 404. A failed validation rule means "this route matched but the input is invalid", which should lead to a 400. They serve completely different purposes.
The most common constraint is the type constraint. {id:int} only matches integer values. {id:guid} only matches GUID format. {date:datetime} only matches valid date strings. There are also range constraints ({id:min(1)}), length constraints ({name:maxlength(50)}), and regular expression constraints ({slug:regex(^[a-z0-9-]+$)}).
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
// Matches integers >= 1: GET /api/products/42
[HttpGet("{id:int:min(1)}")]
public IActionResult GetByIntId(int id)
=> Ok(new { Id = id, Source = "integer lookup" });
// Matches slugs: GET /api/products/by-slug/my-product-name
// Note: doubled brackets are required to escape literal bracket characters in route templates
[HttpGet("by-slug/{slug:regex(^[[a-z0-9-]]+$)}")]
public IActionResult GetBySlug(string slug)
=> Ok(new { Slug = slug });
// Matches GUIDs: GET /api/products/3fa85f64-5717-4562-b3fc-2c963f66afa6
[HttpGet("{id:guid}")]
public IActionResult GetByGuid(Guid id)
=> Ok(new { Id = id, Source = "guid lookup" });
// Multiple constraints chained: int that falls in a range
[HttpGet("page/{page:int:range(1,1000)}/size/{size:int:range(5,100)}")]
public IActionResult GetPage(int page, int size)
=> Ok(new { Page = page, Size = size });
}
Notice that regex patterns inside C# string attributes need doubled brackets for literal bracket characters: [[a-z]] in the attribute becomes [a-z] in the compiled regex. This is a gotcha that bites developers regularly. Constraints are evaluated left to right, so {id:int:min(1)} first checks that the value is an integer, then checks that it is at least 1.
The full list of built-in constraints includes: alpha, bool, datetime, decimal, double, float, guid, int, long, min(value), max(value), minlength(value), maxlength(value), range(min,max), regex(expression), and required. The Microsoft routing documentation maintains the canonical reference.
Custom Route Constraints
When built-in constraints are not enough, you can implement IRouteConstraint to define your own matching logic. A custom constraint is a class with a single Match method that receives the route value and returns a boolean indicating whether the constraint is satisfied.
public sealed class VersionConstraint : IRouteConstraint
{
private static readonly string[] SupportedVersions = ["v1", "v2", "v3"];
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value))
{
return false;
}
var version = value?.ToString()?.ToLowerInvariant();
return SupportedVersions.Contains(version);
}
}
// Registration in Program.cs:
// builder.Services.Configure<RouteOptions>(options =>
// options.ConstraintMap.Add("supportedVersion", typeof(VersionConstraint)));
//
// Usage: [Route("api/{version:supportedVersion}/[controller]")]
This example validates that the version segment is one of your supported versions, returning a 404 for unknown versions. Keep the Match method fast -- avoid database calls or I/O. Constraints run on every request during route matching, before any middleware or action filters execute. This is architecturally similar to how the chain of responsibility pattern works -- the routing engine chains constraints, and each one either passes or rejects the candidate route. Similarly, the proxy pattern is a useful model for route-level interception -- metadata lets middleware proxy, redirect, or gate requests before the handler runs.
Route Parameter Binding
Route values extracted by the routing engine need to be bound to action method parameters. ASP.NET Core does this automatically using a set of binding sources, each represented by a binding source attribute.
[FromRoute] explicitly binds a parameter from route values. [FromQuery] binds from the query string. [FromBody] binds from the request body -- for JSON payloads, System.Text.Json handles deserialization. [FromHeader] binds from request headers. [FromForm] binds from multipart form data.
With [ApiController], the binding source is inferred for most cases. Complex types default to [FromBody]. Simple types that match a route template token default to [FromRoute]. Simple types not in the route template default to [FromQuery]. This inference removes most explicit [From...] annotations from typical CRUD controllers. Being explicit is still worthwhile when a parameter name does not match a route template token, when you need a header value as a parameter, or when you want to make the binding intent obvious to future readers of the code.
Route Names and URL Generation
Named routes let you generate URLs programmatically without hardcoding path strings. You assign a name with the Name property on the HTTP method attribute, then generate URLs using IUrlHelper or LinkGenerator.
CreatedAtAction is the idiomatic way to return a 201 response after a successful POST -- it generates the URL to the newly created resource and sets the Location response header automatically. CreatedAtRoute does the same but uses a named route rather than an action name.
[ApiController]
[Route("api/[controller]")]
public class ArticlesController : ControllerBase
{
[HttpGet("{id:int}", Name = "GetArticle")]
public IActionResult GetById(int id)
=> Ok(new { Id = id, Title = "Sample Article" });
[HttpPost]
public IActionResult Create([FromBody] CreateArticleRequest request)
{
var newId = 42;
// Generates: /api/articles/42 and sets Location header
return CreatedAtAction(nameof(GetById), new { id = newId }, new { Id = newId });
}
[HttpGet("canonical/{slug}")]
public IActionResult GetBySlug(string slug)
{
// Generate a URL using LinkGenerator (works outside of controller context too)
// Useful for building HATEOAS links in response bodies
return Ok(new { Slug = slug });
}
}
public record CreateArticleRequest(string Title, string Body);
LinkGenerator (injected from DI) is the more powerful option. It works outside of controller contexts -- in middleware, background services, event handlers -- and does not require an active HttpContext. You call GetUriByAction(...) or GetPathByAction(...) with the controller name, action name, and route values. This keeps URL generation consistent with your route definitions and automatically adapts when you refactor route templates.
Ordering and Ambiguity Resolution
When multiple routes could match a given request, the routing engine applies a specificity scoring system. Routes with more literal segments score higher than routes with more parameter segments at the same position. Constrained parameters score higher than unconstrained ones. HTTP method constraints (GET vs POST) are considered before URL template scoring.
If genuine ambiguity exists -- two routes that the engine cannot distinguish by specificity -- you will get an AmbiguousMatchException at runtime. The fix is almost always structural: add constraints to differentiate routes ({id:int} vs {slug:regex(...)}), add a distinguishing literal segment, or use different HTTP verbs.
When building larger applications, keeping route prefixes organized by feature or module prevents collisions. A modular monolith architecture naturally enforces this -- each module owns its route prefix and its controllers, so two modules cannot accidentally register conflicting routes. For structured request logging across all routes, Logging in .NET: The Complete Developer's Guide covers how to instrument your pipeline with per-request structured log entries that include the matched route template.
.NET 10 Routing Improvements
.NET 10 brings several incremental improvements to the routing layer. The built-in Microsoft.AspNetCore.OpenApi package uses routing metadata directly to generate accurate OpenAPI documents without requiring [ProducesResponseType] annotations on every action -- the return types from ActionResult<T> and the route template constraints feed directly into the schema generation.
RouteGroupBuilder, introduced in .NET 7 for minimal APIs and significantly improved in .NET 10, makes it easy to group related endpoints with a shared route prefix, authentication policy, and metadata. This is the minimal API equivalent of a controller with a [Route] attribute on the class. Endpoint filters (introduced in .NET 7) work in minimal APIs; controllers use a separate filter pipeline (action filters, result filters, etc.). Both models have continued to improve through .NET 10.
Native AOT support has improved for ASP.NET Core apps in .NET 10, though compatibility still depends on the app's overall feature usage and library choices.
Frequently Asked Questions
What is the difference between conventional routing and attribute routing?
Conventional routing defines URL patterns centrally in Program.cs using MapControllerRoute. The framework derives the controller and action names from URL segment positions. Attribute routing places route definitions directly on controller classes and action methods using [Route], [HttpGet], and related attributes -- the route is co-located with the code it describes.
For Web APIs, attribute routing is commonly preferred. It makes the API surface immediately visible without consulting a central configuration file, and supports more expressive patterns like per-action templates and nested resources. You can mix both styles in the same application -- controllers with [Route] attributes use attribute routing, while controllers without fall under conventional routing.
Why do my route constraints return 404 instead of 400?
This is by design. Constraints are evaluated during route matching, not after a route is matched. If a constraint fails, the engine marks the route as a non-match and moves to the next candidate. If no candidate matches, the result is a 404 -- not a 400.
Constraints answer "does this request match this route?" -- not "is this input valid?". If you want a 400 Bad Request when a client sends an invalid value, use model validation instead. Data annotations and FluentValidation are often the better tools for that. Keep route constraints for routing disambiguation only.
How do I handle versioned APIs with routing?
URL path versioning is the simplest approach: include the version in the route template like api/v{version:int}/[controller]. This makes the version visible in every URL and works with every HTTP client without special headers.
For richer versioning support, the Asp.Versioning.Http NuGet package (third-party, not part of the framework) adds version negotiation, deprecation headers, and per-version OpenAPI generation. It supports URL, query string, and header versioning simultaneously. Start with URL versioning if you are just getting started; migrate to the package if you need more control later.
Can I use route constraints to enforce business rules?
Technically yes, but you should not. Route constraints run during URL matching and must be fast, stateless, and free of side effects. Injecting a database context into a constraint couples routing infrastructure to domain logic and causes queries on every request -- before authentication or authorization has even run.
Use constraints for structural validation only: "is this a valid integer?", "does this match my slug format?". For business rule validation like "does this product ID exist?", handle it in the action method or service layer. The routing layer should be thin and infrastructure-level.
What is the recommended URL structure for a REST API?
REST conventions favor nouns over verbs, with HTTP methods expressing the operation. For an orders resource: GET /api/orders to list, POST /api/orders to create, GET /api/orders/{id} to get one, PUT /api/orders/{id} to replace, PATCH /api/orders/{id} to partially update, and DELETE /api/orders/{id} to delete.
Avoid verbs in URLs like /api/getOrder. They obscure the HTTP method semantics. When an action does not map cleanly to CRUD -- cancelling an order -- use a sub-resource noun: POST /api/orders/{id}/cancellation is cleaner than POST /api/orders/{id}/cancel and opens the door to GET /api/orders/{id}/cancellation later.
How does the routing engine resolve ambiguous routes?
The routing engine scores route candidates by specificity. More literal segments score higher than parameterized segments. Constrained parameters score higher than unconstrained ones. Routes with different HTTP verbs are never ambiguous -- GET /api/products and POST /api/products are distinct by design.
When two routes have equivalent specificity and the same verb, the engine throws AmbiguousMatchException. The fix is almost always: add type constraints to differentiate templates ({id:int} vs {slug:regex(...)}), add a distinguishing literal segment, or restructure the route hierarchy.
How do I generate URLs for routes in my API responses?
For 201 Created responses after a POST, use CreatedAtAction(nameof(GetById), new { id = newId }, body). This generates the URL to the newly created resource, sets the Location header, and returns the body -- the complete RFC 7231 pattern for resource creation.
For building HATEOAS-style links in response bodies, inject LinkGenerator from DI. Unlike IUrlHelper, LinkGenerator works in any context -- middleware, background jobs, SignalR hubs -- without requiring an active HttpContext. Centralizing URL generation through LinkGenerator keeps your links correct when you refactor route templates.

