Overview
Endpoint routing is the ASP.NET Core system that matches incoming HTTP requests to executable endpoints such as controller actions, minimal API handlers, Razor Pages, SignalR hubs, gRPC services, and health check endpoints.
In an API, routing answers questions like:
- Which handler should process
GET /api/products/10? - Should
/api/products/searchgo to a search endpoint or be interpreted as an{id}parameter? - Should
/files/images/2026/logo.pngbe handled by a catch-all route? - Should an invalid route parameter return
404 Not Foundor should model validation return400 Bad Request? - What happens when two routes could both match the same URL?
This topic matters because routing is one of the first design decisions in an ASP.NET Core application. A good route design makes an API predictable, stable, secure, and easy to consume. A poor route design can create ambiguous endpoints, incorrect authorization behavior, broken link generation, hard-to-debug 404 responses, and inconsistent API contracts.
In interviews, this topic is important because it connects several practical ASP.NET Core skills:
- API design
- Controller routing
- Minimal API routing
- Middleware ordering
- Authorization and endpoint metadata
- Model binding
- Route constraints
- REST-style URL design
- Debugging ambiguous or unexpected route matches
A strong candidate should understand not only how to write routes, but also how ASP.NET Core chooses the best route, when to use constraints, when optional parameters are risky, and why catch-all routes should be designed carefully.
Core Concepts
Endpoint Routing
Endpoint routing is the modern ASP.NET Core routing model. It separates route matching from endpoint execution.
A route endpoint usually contains:
- A route pattern, such as
/api/products/{id:int} - A request delegate or action method to execute
- Metadata, such as HTTP method, authorization requirements, CORS policy, filters, endpoint name, or OpenAPI information
Example using minimal APIs:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/products/{id:int}", (int id) =>
{
return Results.Ok(new { Id = id, Name = "Keyboard" });
})
.WithName("GetProductById")
.RequireAuthorization();
app.Run();
In this example:
/api/products/{id:int}is the route pattern.{id:int}captures a route value namedid.:intis a route constraint.MapGetadds HTTP method metadata forGET.RequireAuthorizationadds authorization metadata..WithName(...)gives the endpoint a name useful for link generation.
Endpoint routing is used by both minimal APIs and MVC controllers. The definition style is different, but the routing system still selects an endpoint based on route patterns, HTTP method metadata, constraints, and precedence.
Route Matching Pipeline
ASP.NET Core route matching can be understood as a filtering process.
For an incoming request, ASP.NET Core generally:
- Compares the request path against available route templates.
- Removes candidates that fail route constraints.
- Applies endpoint selection policies, such as HTTP method matching.
- Chooses the highest-priority endpoint.
- Throws an ambiguity error if multiple endpoints have the same priority and no single best match exists.
Example:
app.MapGet("/api/products/list", () => "Product list");
app.MapGet("/api/products/{id:int}", (int id) => $"Product {id}");
Requests:
GET /api/products/list -> matches /api/products/list
GET /api/products/10 -> matches /api/products/{id:int}
GET /api/products/abc -> no match for {id:int}, usually 404
The literal route /api/products/list is more specific than the parameter route /api/products/{id:int}.
Endpoint Routing and Middleware Ordering
Routing is closely related to middleware ordering.
A common ASP.NET Core pipeline looks like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
In modern minimal-hosting ASP.NET Core applications, the framework automatically places routing and endpoint execution around mapped endpoints in common scenarios. However, understanding the conceptual order is still important:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
The important idea is:
- Middleware before routing cannot see the selected endpoint.
- Middleware after routing and before endpoint execution can inspect endpoint metadata.
- Authorization middleware must run after routing has selected an endpoint so it can read endpoint authorization metadata.
- Endpoint execution is terminal when an endpoint is matched.
This is why endpoint metadata matters. For example:
app.MapGet("/admin/reports", () => "Admin reports")
.RequireAuthorization("AdminOnly");
The route itself defines authorization metadata. The authorization middleware reads that metadata before the endpoint runs.
Attribute Routing
Attribute routing defines routes directly on controllers and actions.
Example:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
return Ok();
}
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok(new { Id = id });
}
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
return CreatedAtAction(nameof(GetById), new { id = 10 }, request);
}
}
Resulting routes:
GET /api/products
GET /api/products/10
POST /api/products
In attribute routing:
- The route on the controller is usually a shared prefix.
- The route on the action is appended to the controller route.
- HTTP verb attributes such as
[HttpGet],[HttpPost], and[HttpDelete]also define route templates. - Controller and action names do not affect route matching unless token replacement is used.
Combining Controller and Action Routes
A controller-level route can be combined with an action-level route.
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok();
}
}
The final route is:
GET /api/orders/{id:int}
If an action route starts with / or ~/, it becomes an absolute route and is not combined with the controller route.
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet("/health/orders")]
public IActionResult Health()
{
return Ok("Orders API is healthy");
}
}
The final route is:
GET /health/orders
not:
GET /api/orders/health/orders
This can be useful, but it can also surprise developers during refactoring.
HTTP Verb Attributes
HTTP verb attributes restrict a route to a specific HTTP method.
Common attributes include:
[HttpGet][HttpPost][HttpPut][HttpPatch][HttpDelete][HttpHead]
Example:
[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok();
}
[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
return NoContent();
}
}
Both actions use the same route template, but different HTTP methods:
GET /api/customers/5
DELETE /api/customers/5
This is a common REST-style API design.
Route Templates
A route template describes the URL pattern an endpoint can match.
Examples:
api/products
api/products/{id}
api/products/{id:int}
api/orders/{orderId:int}/items/{itemId:int}
files/{**path}
A route template can contain:
- Literal segments
- Route parameters
- Optional parameters
- Default values
- Constraints
- Catch-all parameters
- Complex segments
- Parameter transformers
Literal Segments
Literal segments must match the URL path text.
app.MapGet("/api/products/search", () => "Search products");
This route matches:
GET /api/products/search
It does not match:
GET /api/products/10
GET /api/products/SearchByName
Literal routes are more specific than parameter routes, so they usually win when both could match.
Route Parameters
A route parameter captures part of the URL into a named value.
app.MapGet("/api/products/{id}", (string id) =>
{
return Results.Ok($"Product id: {id}");
});
For:
GET /api/products/abc123
id is captured as:
abc123
In controllers:
[HttpGet("{id}")]
public IActionResult GetById(string id)
{
return Ok(id);
}
Route parameters are commonly used for resource identifiers.
Typed Route Parameters in Handlers
In minimal APIs and controllers, route values can be bound to typed parameters.
app.MapGet("/api/products/{id:int}", (int id) =>
{
return Results.Ok(id);
});
The route constraint ensures that only integer-looking values match. The handler parameter is then bound as an int.
Without the constraint:
app.MapGet("/api/products/{id}", (int id) =>
{
return Results.Ok(id);
});
a non-integer path may still match the route pattern, then fail during binding or validation depending on the endpoint style and configuration. For public APIs, route constraints make the intended URL shape clearer.
Route Constraints
Route constraints limit which URL values can match a route parameter.
Example:
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok();
}
[HttpGet("{slug:alpha}")]
public IActionResult GetBySlug(string slug)
{
return Ok();
}
Possible behavior:
GET /api/products/123 -> GetById
GET /api/products/keyboard -> GetBySlug
GET /api/products/abc123 -> no match
Common route constraints include:
Multiple constraints can be combined:
[HttpGet("{id:int:min(1)}")]
public IActionResult GetById(int id)
{
return Ok();
}
This route requires id to be an integer and at least 1.
Constraints Are Not Input Validation
A common interview mistake is saying route constraints are validation.
They are not a replacement for request validation.
Route constraints are mainly for route disambiguation and URL shape matching. If a constraint fails, the route does not match, and the client typically receives 404 Not Found.
Validation should return 400 Bad Request with a useful error message.
Example:
[HttpGet("{id:int:min(1)}")]
public IActionResult GetById(int id)
{
return Ok();
}
Request:
GET /api/products/abc
This does not match the route because abc is not an integer. A 404 response is expected.
Request:
GET /api/products/0
This does not match if min(1) is used. A 404 response is expected.
If the API needs to tell the client that 0 is invalid input, handle it through validation instead:
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
if (id <= 0)
{
return BadRequest("Product id must be greater than zero.");
}
return Ok();
}
Optional Parameters
Optional route parameters are marked with ?.
[HttpGet("products/{id?}")]
public IActionResult GetProduct(int? id)
{
if (id is null)
{
return Ok("All products");
}
return Ok($"Product {id}");
}
Matches:
GET /products
GET /products/10
Optional parameters are useful in some MVC-style pages, but they should be used carefully in APIs.
For APIs, it is often clearer to define separate endpoints:
[HttpGet("products")]
public IActionResult GetProducts()
{
return Ok();
}
[HttpGet("products/{id:int}")]
public IActionResult GetProductById(int id)
{
return Ok();
}
This gives each endpoint a clear purpose and avoids overloaded route behavior.
Optional Parameters vs Default Values
Optional parameters and default values are similar but not the same.
Optional parameter:
{category?}
A value is produced only when the segment exists.
Default value:
{category=all}
A value is produced even when the segment does not exist.
Example:
app.MapGet("/products/{category=all}", (string category) =>
{
return Results.Ok($"Category: {category}");
});
Request:
GET /products
The route value is:
category = all
For APIs, default route values are more common in conventional MVC routes than in explicit REST-style endpoints.
Catch-All Routes
A catch-all route captures the rest of the URL path.
app.MapGet("/files/{**path}", (string? path) =>
{
return Results.Ok($"Requested file path: {path}");
});
Matches:
GET /files/readme.txt
GET /files/images/products/keyboard.png
GET /files/
Catch-all parameters are useful for:
- File path routing
- CMS pages
- Slug-based pages
- Documentation routes
- Fallback routes for single-page applications
- Proxy-like endpoints
Two forms exist:
{*path}
{**path}
The difference matters most during URL generation:
{*path}escapes path separators when generating links.{**path}preserves path separators when generating links.
For route matching, both can capture multiple path segments. For URL generation, {**path} is usually the better choice when the captured value is intended to remain a path.
Catch-All Routes Should Usually Be Last Conceptually
Catch-all routes are greedy because they can match many URLs.
Example:
app.MapGet("/api/{**path}", (string path) => $"Fallback API path: {path}");
app.MapGet("/api/products/{id:int}", (int id) => $"Product {id}");
In endpoint routing, route precedence usually prevents a catch-all from beating a more specific route. However, catch-all routes should still be designed carefully because they can make APIs harder to reason about and can hide mistakes.
A better design is often:
app.MapGet("/api/products/{id:int}", (int id) => $"Product {id}");
app.MapGet("/api/{**path}", (string path) => Results.NotFound());
For conventional routing, greedy routes should be placed later because conventional routing is order-dependent.
Route Precedence
Route precedence is the system ASP.NET Core uses to choose the most specific route when multiple endpoints could match.
General rules:
- More segments are usually more specific.
- Literal segments are more specific than parameter segments.
- Constrained parameters are more specific than unconstrained parameters.
- Complex segments are treated as more specific than simple unconstrained parameters.
- Catch-all parameters are the least specific.
Example:
app.MapGet("/products/list", () => "List");
app.MapGet("/products/{id}", (string id) => $"Product {id}");
app.MapGet("/products/{id:int}", (int id) => $"Product {id}");
app.MapGet("/products/{**path}", (string path) => $"Fallback {path}");
Possible matches:
GET /products/list -> /products/list
GET /products/123 -> /products/{id:int}
GET /products/abc -> /products/{id}
GET /products/a/b/c -> /products/{**path}
The more specific route wins.
Ambiguous Routes
Ambiguous routes happen when ASP.NET Core cannot choose a single best endpoint.
Example:
app.MapGet("/products/{value}", (string value) => $"By value: {value}");
app.MapGet("/products/{name}", (string name) => $"By name: {name}");
Both routes have the same shape and priority. A request like:
GET /products/keyboard
could match both. ASP.NET Core cannot know which one is intended.
Better:
app.MapGet("/products/by-code/{code}", (string code) => $"By code: {code}");
app.MapGet("/products/by-name/{name}", (string name) => $"By name: {name}");
or use constraints when the shapes are truly different:
app.MapGet("/products/{id:int}", (int id) => $"By id: {id}");
app.MapGet("/products/{slug:alpha}", (string slug) => $"By slug: {slug}");
Route Order
Endpoint routing mostly relies on route precedence instead of registration order. In common cases, you should not need to manually set route order.
However, order can still matter in some scenarios:
- Conventional MVC routing
- Explicit
Ordervalues on route endpoints - Greedy catch-all conventional routes
- Fallback routes
- Routes with identical precedence where ambiguity must be avoided
For conventional controller routes:
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
The more specific area route is placed before the default route.
For attribute routing and minimal APIs, prefer unique, clear route templates instead of relying on manual order.
Conventional Routing vs Attribute Routing
Conventional routing defines a pattern centrally.
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
This can match:
/Home/Index/10
/Products/Details/5
Attribute routing defines routes on actions.
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok();
}
}
Comparison:
For APIs, attribute routing is generally preferred because it makes the public API contract explicit.
Token Replacement
Attribute routes can use tokens:
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("[action]/{id:int}")]
public IActionResult Details(int id)
{
return Ok();
}
}
This produces:
GET /api/products/details/10
Common tokens:
[controller][action][area]
Token replacement can reduce repetition, but it can also make public URLs dependent on class and method names. For public APIs, be careful because renaming a controller or action can unintentionally break routes.
A more stable route is often:
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok();
}
}
Reserved Route Parameter Names
Some route names have special meaning in MVC and Razor Pages routing, such as:
actionareacontrollerhandlerpage
Avoid using these names as normal business parameters in controller or Razor Pages routes.
Risky:
[HttpGet("documents/{page}")]
public IActionResult GetDocumentPage(string page)
{
return Ok();
}
Better:
[HttpGet("documents/{pageNumber:int}")]
public IActionResult GetDocumentPage(int pageNumber)
{
return Ok();
}
Using reserved names incorrectly can cause confusing link generation or route matching behavior.
Route Values and Model Binding
Route parameters become route values and can be bound to action parameters.
[HttpGet("orders/{orderId:int}/items/{itemId:int}")]
public IActionResult GetOrderItem(int orderId, int itemId)
{
return Ok(new { orderId, itemId });
}
Request:
GET /orders/100/items/5
Bound values:
orderId = 100
itemId = 5
Route value names should match method parameter names unless explicit binding is used.
[HttpGet("orders/{id:int}")]
public IActionResult GetOrder([FromRoute(Name = "id")] int orderId)
{
return Ok(orderId);
}
This is valid, but matching names are clearer:
[HttpGet("orders/{orderId:int}")]
public IActionResult GetOrder(int orderId)
{
return Ok(orderId);
}
Route Design for REST APIs
For REST-style APIs, route templates should usually model resources, not actions.
Prefer:
GET /api/products
GET /api/products/{id}
POST /api/products
PUT /api/products/{id}
PATCH /api/products/{id}
DELETE /api/products/{id}
Avoid action-heavy routes when standard HTTP methods already communicate intent:
GET /api/products/getAllProducts
POST /api/products/createProduct
POST /api/products/deleteProduct/10
Action-like routes can still be appropriate for operations that do not map cleanly to CRUD:
POST /api/orders/{id}/cancel
POST /api/invoices/{id}/send
POST /api/reports/monthly:generate
The important habit is consistency.
Nested Resource Routes
Nested routes express relationships between resources.
[ApiController]
[Route("api/orders/{orderId:int}/items")]
public class OrderItemsController : ControllerBase
{
[HttpGet]
public IActionResult GetItems(int orderId)
{
return Ok();
}
[HttpGet("{itemId:int}")]
public IActionResult GetItem(int orderId, int itemId)
{
return Ok();
}
}
Routes:
GET /api/orders/10/items
GET /api/orders/10/items/5
Nested routes are useful when the child resource is naturally scoped by the parent. Avoid overly deep nesting such as:
/api/customers/1/orders/2/items/3/discounts/4
Very deep routes can become hard to maintain. Consider query parameters or separate top-level resources when relationships become complex.
Query String vs Route Parameters
Route parameters are best for identifying resources.
GET /api/products/10
Query strings are best for filtering, sorting, searching, and paging.
GET /api/products?category=books&page=2&pageSize=20
Good route design:
[HttpGet]
public IActionResult SearchProducts(
[FromQuery] string? category,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
return Ok();
}
[HttpGet("{id:int}")]
public IActionResult GetProductById(int id)
{
return Ok();
}
Avoid putting many optional filters into the path:
/api/products/books/active/price/10/100/page/2
That style is harder to evolve and understand.
Minimal API Route Groups
Minimal APIs can group routes with a shared prefix and shared metadata.
var products = app.MapGroup("/api/products")
.RequireAuthorization()
.WithTags("Products");
products.MapGet("/", () => "All products");
products.MapGet("/{id:int}", (int id) => $"Product {id}");
products.MapPost("/", (CreateProductRequest request) =>
{
return Results.Created($"/api/products/10", request);
});
Route groups help avoid repetition and keep related endpoints together.
They are useful for:
- Shared authorization
- API version prefixes
- Tags for OpenAPI
- Shared filters
- Common route prefixes
Fallback Routes
Fallback routes are used when no other route matches.
They are common for single-page applications:
app.MapFallbackToFile("index.html");
A custom fallback could look like:
app.MapFallback(() => Results.NotFound(new
{
Message = "The requested API endpoint was not found."
}));
Fallback routes should be used carefully in APIs. A fallback that returns 200 OK for unknown API paths can hide client errors and make debugging harder.
Link Generation
Routing is not only for matching incoming requests. It is also used to generate URLs.
In controllers:
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
var id = 10;
return CreatedAtAction(
nameof(GetById),
new { id },
new { Id = id, request.Name });
}
[HttpGet("{id:int}", Name = "GetProductById")]
public IActionResult GetById(int id)
{
return Ok();
}
Named routes can make link generation more stable:
return CreatedAtRoute(
"GetProductById",
new { id = 10 },
new { Id = 10 });
In minimal APIs:
app.MapGet("/api/products/{id:int}", (int id) => Results.Ok())
.WithName("GetProductById");
app.MapPost("/api/products", (LinkGenerator links, HttpContext context) =>
{
var uri = links.GetUriByName(context, "GetProductById", new { id = 10 });
return Results.Created(uri!, new { Id = 10 });
});
Link generation helps avoid hardcoding URLs throughout the application.
Common Mistakes
Common mistakes include:
- Relying on route constraints as business validation.
- Creating ambiguous routes with the same shape.
- Using optional parameters too broadly in APIs.
- Creating greedy catch-all routes without considering precedence.
- Mixing conventional and attribute routing without a clear reason.
- Using
[action]token replacement in public APIs and accidentally changing URLs during refactoring. - Using reserved route names as business parameter names.
- Returning
200 OKfrom fallback routes for unknown API paths. - Making routes action-based when resource-based routes would be clearer.
- Adding route parameters for filters that belong in the query string.
- Forgetting HTTP method attributes on controller actions.
- Expecting route registration order to solve route ambiguity in endpoint routing.
Best Practices
Practical best practices:
- Prefer attribute routing for Web APIs.
- Use resource-oriented route names.
- Use HTTP methods to represent operations where appropriate.
- Use route constraints to disambiguate similar routes.
- Use validation for invalid business input.
- Keep route templates explicit and stable.
- Prefer separate endpoints over overloaded optional parameters.
- Put filters, sorting, searching, and paging in query strings.
- Avoid deep nested routes unless the hierarchy is essential.
- Avoid catch-all routes unless there is a clear use case.
- Name important endpoints for link generation.
- Keep route parameter names consistent with method parameter names.
- Test important route behaviors with integration tests.
Example integration test idea:
[Fact]
public async Task GetProduct_WithNonIntegerId_ReturnsNotFound()
{
await using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/products/abc");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Integration tests are especially useful for verifying route constraints, authorization behavior, and ambiguous route fixes.