Why routing knowledge is tested in .NET interviews
Routing is how every HTTP request finds its handler. Interviewers probe it because wrong route design causes security holes (wrong controller handles a sensitive route), incorrect behavior (ambiguous routes match the wrong action), and broken clients (URLs change and break existing integrations). Understanding routing deeply signals architectural maturity.
How endpoint routing works
Endpoint routing (ASP.NET Core 3.0+) separates route matching from route dispatching.
UseRouting matches the request to an endpoint and stores the result in HttpContext.
Middleware running between UseRouting and MapControllers can read that endpoint metadata:
app.UseRouting(); // 1. Match: sets HttpContext.GetEndpoint()
app.UseAuthentication(); // 2. Can now inspect [Authorize] on the matched endpoint
app.UseAuthorization(); // 3. Enforce auth before dispatch
app.MapControllers(); // 4. Dispatch: actually run the matched action
This is why UseAuthorization must come after UseRouting — authorization middleware needs
to know which endpoint matched before it can check [Authorize] attributes.
Conventional routing vs attribute routing
Conventional routing defines URL patterns centrally. Every controller that doesn't have
its own [Route] is matched by these patterns:
// MVC — one pattern for all controllers:
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// /Products/Details/5 → ProductsController.Details(id: 5)
// / → HomeController.Index()
// /Blog/Post/42 → BlogController.Post(id: 42)
Attribute routing puts templates directly on controllers and actions:
[ApiController]
[Route("api/[controller]")] // [controller] expands to "products"
public class ProductsController : ControllerBase
{
[HttpGet] // GET /api/products
public IActionResult List() => Ok(_products);
[HttpGet("{id:int}")] // GET /api/products/5
public IActionResult Get(int id) => Ok(Find(id));
[HttpPost] // POST /api/products
public IActionResult Create([FromBody] CreateProductDto dto) { ... }
[HttpPut("{id:int}")] // PUT /api/products/5
public IActionResult Update(int id, [FromBody] UpdateProductDto dto) { ... }
[HttpDelete("{id:int}")] // DELETE /api/products/5
public IActionResult Delete(int id) { ... }
}
When to use which:
- Attribute routing: Web APIs, REST endpoints, fine-grained URL control.
- Conventional routing: MVC apps with consistent
controller/action/idURLs. [ApiController]requires attribute routing — it disables conventional routing for the controller.
Route templates in depth
// Literal segment — exact match required:
[HttpGet("api/v1/status")] // only /api/v1/status
// Route parameter — captures one path segment:
[HttpGet("products/{id}")] // /products/42 → id = "42"
// Optional parameter — segment may be absent:
[HttpGet("search/{term?}")] // /search and /search/shoes both match
public IActionResult Search(string? term) { ... }
// Default value:
[HttpGet("page/{number=1}")] // /page → number=1; /page/3 → number=3
// Multiple parameters:
[HttpGet("{year:int}/{month:int}/{slug}")]
// /2026/6/my-post → year=2026, month=6, slug="my-post"
// Catch-all parameter — captures the rest including slashes:
[HttpGet("files/{**path}")] // /files/a/b/c.txt → path = "a/b/c.txt"
Token replacement:
[controller]→ controller class name minus "Controller" suffix[action]→ action method name[area]→ area name
Route constraints — right tool for the right job
Constraints restrict which requests a route matches. They are specified with a colon:
[HttpGet("{id:int}")] // id must be parseable as int
[HttpGet("{id:int:min(1)}")] // int ≥ 1
[HttpGet("{id:guid}")] // GUID format
[HttpGet("{slug:alpha}")] // letters only
[HttpGet("{date:datetime}")] // parseable DateTime
[HttpGet("{code:length(5)}")] // exactly 5 characters
[HttpGet("{zip:regex(^\\d{{5}}$)}")] // 5-digit zip code
// These two routes are unambiguous because of constraints:
[HttpGet("{id:int}")] // matches /products/5
[HttpGet("{name:alpha}")] // matches /products/widget
Custom route constraint:
public class EvenConstraint : IRouteConstraint
{
public bool Match(HttpContext? httpContext, IRouter? route,
string routeKey, RouteValueDictionary values, RouteDirection direction)
{
return values.TryGetValue(routeKey, out var val)
&& int.TryParse(val?.ToString(), out int n)
&& n % 2 == 0;
}
}
builder.Services.Configure<RouteOptions>(o =>
o.ConstraintMap["even"] = typeof(EvenConstraint));
app.MapGet("/items/{id:even}", (int id) => $"Even: {id}");
Important: route constraints are for routing, not validation. A constraint of
{id:int:min(1)} rejects non-integer URLs at the routing layer, but you still need business
validation in the action for input correctness. A URL that doesn't match any route returns 404,
not 400 — the client may be confused if you rely on constraints for validation.
Link generation
Always generate URLs programmatically — hard-coded strings break when you rename a route:
// In a controller — Url helper:
[HttpPost]
public IActionResult Create([FromBody] CreateOrderDto dto)
{
var order = _svc.Create(dto);
var url = Url.Action("Get", "Orders", new { id = order.Id });
return Created(url, order); // Location: /api/orders/42
}
// Named route:
[HttpGet("{id:int}", Name = "GetOrder")]
public IActionResult Get(int id) => Ok(Find(id));
var url = Url.RouteUrl("GetOrder", new { id = 5 }); // /api/orders/5
// LinkGenerator in DI (works outside controllers):
public class NotificationService
{
private readonly LinkGenerator _links;
public NotificationService(LinkGenerator links) => _links = links;
public string GetOrderUrl(int orderId)
=> _links.GetPathByName("GetOrder", new { id = orderId })!;
}
For minimal APIs, use .WithName() to enable link generation:
app.MapGet("/orders/{id:int}", (int id) => GetOrder(id))
.WithName("get-order");
app.MapPost("/orders", (CreateOrderDto dto, LinkGenerator links) =>
{
var order = CreateOrder(dto);
var url = links.GetPathByName("get-order", new { id = order.Id });
return Results.Created(url, order);
});
Minimal APIs and route groups
Minimal APIs define endpoints with lambdas — no controller class needed:
// Route group: common prefix + shared policies
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/", async (AppDb db) =>
await db.Orders.ToListAsync());
orders.MapGet("/{id:int}", async (int id, AppDb db) =>
await db.Orders.FindAsync(id) is Order o ? Results.Ok(o) : Results.NotFound());
orders.MapPost("/", async (CreateOrderDto dto, AppDb db) =>
{
var order = new Order { Item = dto.Item };
db.Orders.Add(order);
await db.SaveChangesAsync();
return Results.Created($"/api/orders/{order.Id}", order);
});
orders.MapDelete("/{id:int}", async (int id, AppDb db) =>
{
if (await db.Orders.FindAsync(id) is Order o)
{
db.Orders.Remove(o);
await db.SaveChangesAsync();
return Results.NoContent();
}
return Results.NotFound();
});
Route groups eliminate repetition — the /api/orders prefix, RequireAuthorization(), and
the WithTags("Orders") Swagger tag apply to every endpoint in the group.
Routing ambiguity — and how to avoid it
When two routes match with equal specificity, AmbiguousMatchException is thrown at runtime:
// Ambiguous — both match GET /products/5:
[HttpGet("{id}")]
public IActionResult GetById(string id) { ... }
[HttpGet("{name}")]
public IActionResult GetByName(string name) { ... }
// Fixed with constraints:
[HttpGet("{id:int}")] // only integers
[HttpGet("{name:alpha}")] // only letters
// Or with distinct templates:
[HttpGet("by-id/{id:int}")]
[HttpGet("by-name/{name}")]
For conventional routing, more specific routes must be registered first:
app.MapControllerRoute("featured", "products/featured", ...); // specific first
app.MapControllerRoute("default", "products/{id}", ...); // generic second
HTTP verb attributes and REST conventions
The HTTP verb attributes constrain routes to specific methods and follow REST semantics:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet] // GET /api/orders — list collection
[HttpGet("{id:int}")] // GET /api/orders/5 — get single
[HttpPost] // POST /api/orders — create
[HttpPut("{id:int}")] // PUT /api/orders/5 — full replace
[HttpPatch("{id:int}")] // PATCH /api/orders/5 — partial update
[HttpDelete("{id:int}")] // DELETE /api/orders/5 — delete
[HttpHead("{id:int}")] // HEAD /api/orders/5 — existence check
}
[AcceptVerbs] for multi-verb actions:
[AcceptVerbs("GET", "HEAD")]
public IActionResult GetOrHead(int id) { ... }
Never use GET for operations with side effects — caches and proxies assume GET is safe and idempotent, which breaks if GET mutates state.
Recap
Endpoint routing separates matching (UseRouting) from dispatch (MapControllers), letting
middleware between them inspect endpoint metadata. Attribute routing gives per-action URL
control and is required with [ApiController]. Route constraints restrict matching — use them
for routing, not input validation. Always generate URLs with IUrlHelper or LinkGenerator,
never hard-code strings. Use MapGroup in minimal APIs to share prefixes and policies across
related endpoints. Resolve ambiguity with constraints or distinct templates — never rely on
registration order for correctness.