Routing maps incoming HTTP requests to endpoints (controller actions, Razor Pages,
or minimal API handlers) based on the URL path, HTTP method, and other metadata.
Endpoint routing (introduced in ASP.NET Core 3.0) separates route matching
(UseRouting) from route execution (MapControllers), allowing middleware in
between to read endpoint metadata before the action runs.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting(); // 1. Match the request to an endpoint
app.UseAuthentication(); // 2. Run auth AFTER matching (can see endpoint metadata)
app.UseAuthorization(); // 3. Enforce authorization policy from endpoint metadata
app.MapControllers(); // 4. Execute the matched controller action
app.Run();
The key advantage of this design: UseAuthorization can read the [Authorize]
attribute on the matched endpoint before dispatching, so it can reject unauthorized
requests without ever entering the controller method.
Rule of thumb: Always place UseRouting before UseAuthentication /
UseAuthorization, and those before MapControllers. This is the order that lets
middleware inspect endpoint metadata correctly.
Conventional routing defines URL patterns in a central place (MapControllerRoute).
Attribute routing places route templates directly on controllers and actions with
[Route], [HttpGet], [HttpPost], etc.
// Conventional routing — one pattern matches many controllers:
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// /Products/Details/5 → ProductsController.Details(id: 5)
// /Home → HomeController.Index()
// Attribute routing — template lives on the action:
[ApiController]
[Route("api/[controller]")] // api/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] Product p) { ... }
[HttpDelete("{id:int}")] // DELETE api/products/5
public IActionResult Delete(int id) { ... }
}
| Aspect | Conventional | Attribute |
|---|---|---|
| Route definition | Program.cs |
Decorators on class/method |
| Best for | MVC views (consistent URL structure) | REST APIs (per-action control) |
| Override | Attribute wins | N/A — all attribute |
Required for [ApiController] |
No | Yes |
Rule of thumb: Use conventional routing for MVC Razor view apps where URLs follow
a predictable pattern. Use attribute routing for Web APIs — [ApiController] requires it.
Route constraints restrict which requests match a route parameter by type,
format, or range. They are specified in the route template with a colon (:).
// Built-in constraints:
[HttpGet("{id:int}")] // id must parse as int
[HttpGet("{id:int:min(1)}")] // int ≥ 1
[HttpGet("{slug:alpha}")] // letters only
[HttpGet("{id:guid}")] // GUID format
[HttpGet("{date:datetime}")] // parseable DateTime
[HttpGet("{code:length(5)}")] // exactly 5 characters
[HttpGet("{code:regex(^[A-Z]{{3}}\\d{{2}}$)}")] // regex pattern
// Example — accept only positive integer product ids:
[HttpGet("products/{id:int:min(1)}")]
public IActionResult GetProduct(int id)
{
var product = _repo.Find(id);
return product is null ? NotFound() : Ok(product);
}
// Minimal API style:
app.MapGet("/orders/{id:int}", (int id) => FindOrder(id));
Custom constraint:
public class EvenNumberConstraint : 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;
}
}
// Register:
builder.Services.Configure<RouteOptions>(o =>
o.ConstraintMap.Add("even", typeof(EvenNumberConstraint)));
// Use:
app.MapGet("/items/{id:even}", (int id) => $"Even id: {id}");
Rule of thumb: Prefer built-in constraints for type and range validation. Route constraints are for routing — not business validation. Always validate input again in your action method or command handler.
A route template is a pattern string composed of literal segments, route
parameters ({name}), optional parameters ({name?}), default values
({name=default}), and catch-all parameters ({**rest} or {*rest}).
// Literal segment — must match exactly:
[HttpGet("api/v1/products")] // only matches /api/v1/products
// Route parameter — captures a URL segment:
[HttpGet("products/{id}")] // captures: id = "42" for /products/42
// Optional parameter:
[HttpGet("search/{term?}")] // matches /search and /search/shoes
public IActionResult Search(string? term) { ... }
// Default value:
[HttpGet("page/{number=1}")] // /page → number=1; /page/3 → number=3
// Catch-all — captures the remainder including slashes:
[HttpGet("files/{**path}")] // /files/a/b/c → path = "a/b/c"
public IActionResult GetFile(string path) { ... }
// Multiple parameters:
[HttpGet("{year:int}/{month:int}/{slug}")]
// /2026/6/my-post → year=2026, month=6, slug="my-post"
public IActionResult GetPost(int year, int month, string slug) { ... }
Token replacement in attribute routing:
[Route("api/[controller]")] // [controller] → "Products" (class name minus suffix)
[Route("api/[controller]/[action]")] // [action] → method name
Rule of thumb: Use {name} for required segments, {name?} for optional
segments, and {**rest} for path-like catch-all values that include /.
The HTTP verb attributes constrain a route to a specific HTTP method and optionally add a path template. They implement the REST convention of using different HTTP methods to represent different operations on the same resource URL.
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet] // GET /api/orders — list all
public IActionResult List() => Ok(_orders);
[HttpGet("{id:int}")] // GET /api/orders/5 — get one
public IActionResult Get(int id) => Ok(Find(id));
[HttpPost] // POST /api/orders — create
public IActionResult Create([FromBody] CreateOrderDto dto) { ... }
[HttpPut("{id:int}")] // PUT /api/orders/5 — full replace
public IActionResult Replace(int id, [FromBody] OrderDto dto) { ... }
[HttpPatch("{id:int}")] // PATCH /api/orders/5 — partial update
public IActionResult Update(int id, [FromBody] JsonPatchDocument<Order> patch) { ... }
[HttpDelete("{id:int}")] // DELETE /api/orders/5 — delete
public IActionResult Delete(int id) { ... }
[HttpHead("{id:int}")] // HEAD /api/orders/5 — check existence
public IActionResult Exists(int id) { ... }
}
[AcceptVerbs("GET", "POST")] is the multi-verb variant:
[AcceptVerbs("GET", "POST")]
public IActionResult Search(string? q) { ... }
Rule of thumb: Follow REST conventions — GET for reads (idempotent, cacheable), POST for creates, PUT for full updates, PATCH for partial updates, DELETE for removal. Never use GET for actions with side effects.
IUrlHelper is available in controllers and Razor Pages. LinkGenerator
is a DI service usable anywhere, including middleware and background services.
// In a controller — IUrlHelper via Url property:
public class OrdersController : ControllerBase
{
[HttpPost]
public IActionResult Create([FromBody] CreateOrderDto dto)
{
var order = _service.Create(dto);
// Generate URL for the Get action:
var url = Url.Action(
action: "Get",
controller: "Orders",
values: new { id = order.Id });
// → /api/orders/42
return Created(url, order); // 201 with Location header
}
}
// Minimal APIs — LinkGenerator in DI:
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);
});
app.MapGet("/orders/{id:int}", (int id) => GetOrder(id))
.WithName("get-order"); // named endpoint for link generation
Url.RouteUrl uses the route name; Url.Action uses controller/action names.
// Using named routes:
[HttpGet("{id:int}", Name = "GetOrder")]
public IActionResult Get(int id) => Ok(Find(id));
var url = Url.RouteUrl("GetOrder", new { id = 5 }); // → /api/orders/5
Rule of thumb: Always use IUrlHelper or LinkGenerator to generate URLs
rather than hard-coding strings — route templates can change and named routes
ensure your links stay correct.
Areas partition a large MVC application into functional groups, each with its
own controllers, views, and models. They are useful for multi-module applications
(e.g., Admin and Public sections with separate HomeController classes).
// Directory structure:
// Areas/
// Admin/
// Controllers/ProductsController.cs
// Views/Products/Index.cshtml
// Shop/
// Controllers/ProductsController.cs
// Views/Products/Index.cshtml
// Controller — mark with [Area]:
[Area("Admin")]
[Route("admin/[controller]/[action]")]
public class ProductsController : Controller
{
public IActionResult Index() => View();
}
// Register area routing (in addition to conventional routing):
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
// /admin/products → Admin area, ProductsController.Index
// /shop/products → Shop area, ProductsController.Index
Link generation across areas requires specifying area:
// In a Razor view inside the Shop area, link to Admin:
<a asp-area="Admin" asp-controller="Products" asp-action="Index">
Admin Products
</a>
Rule of thumb: Use Areas when a single project needs distinct sections with overlapping controller or view names (Admin, API, Shop). For true separation, prefer separate projects. Areas add complexity — avoid them for small apps.
Minimal APIs (ASP.NET Core 6+) let you define HTTP endpoints with lambda
expressions directly in Program.cs, without controllers, attributes, or
ControllerBase. They are simpler and have less overhead.
// Minimal API — no controller class needed:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseInMemoryDatabase("orders"));
var app = builder.Build();
app.MapGet("/orders", async (AppDb db) =>
await db.Orders.ToListAsync());
app.MapGet("/orders/{id:int}", async (int id, AppDb db) =>
await db.Orders.FindAsync(id) is Order o ? Results.Ok(o) : Results.NotFound());
app.MapPost("/orders", async (CreateOrderDto dto, AppDb db) =>
{
var order = new Order { Item = dto.Item };
db.Orders.Add(order);
await db.SaveChangesAsync();
return Results.Created($"/orders/{order.Id}", order);
});
app.Run();
| Aspect | Minimal API | Controller |
|---|---|---|
| Boilerplate | Minimal | More (class, ControllerBase, attributes) |
| Built-in features | Manual | Model binding, ModelState, filters, conventions |
| Testability | Lambda — needs WebApplicationFactory |
Easier to unit-test in isolation |
| Organisation | Flat in Program.cs | Class-based, scales better |
| Best for | Microservices, simple endpoints | Complex domains with many actions |
Rule of thumb: Start with minimal APIs for new small services and microservices. Switch to controllers when you need structured organization, action filters, complex model binding, or per-action conventions across many endpoints.
Route groups (MapGroup) let you apply a common prefix, middleware, filters,
and metadata to a set of related endpoints without repeating them on each one.
var api = app.MapGroup("/api"); // common prefix for all API routes
var v1 = api.MapGroup("/v1"); // /api/v1
var orders = v1.MapGroup("/orders")
.RequireAuthorization() // all /api/v1/orders endpoints need auth
.WithTags("Orders"); // Swagger tag
orders.MapGet("/", GetAllOrders); // GET /api/v1/orders
orders.MapGet("/{id:int}", GetOrder); // GET /api/v1/orders/5
orders.MapPost("/", CreateOrder); // POST /api/v1/orders
orders.MapDelete("/{id:int}", DeleteOrder); // DELETE /api/v1/orders/5
// Endpoint filters apply to all endpoints in the group:
orders.AddEndpointFilter(async (ctx, next) =>
{
Console.WriteLine($"Before: {ctx.HttpContext.Request.Path}");
var result = await next(ctx);
Console.WriteLine("After");
return result;
});
Without groups, each MapGet/MapPost would repeat /api/v1/orders and
.RequireAuthorization().
Rule of thumb: Use MapGroup to co-locate related endpoints and apply
shared policies (auth, rate limiting, OpenAPI tags) in one place. Group by
resource or feature — mirroring what controllers do naturally.
ASP.NET Core binds parameters from multiple sources. For controllers with
[ApiController], the binding source is inferred; without it, you must specify.
[HttpGet("products/{id}")]
public IActionResult Get(
int id, // [FromRoute] inferred — from URL segment
[FromQuery] string? sort, // from ?sort=name
[FromHeader(Name="X-Version")] string version, // from request header
[FromServices] IProductRepo repo) // injected from DI
=> Ok(repo.Find(id, sort));
[HttpPost("products")]
public IActionResult Create(
[FromBody] CreateProductDto dto) // JSON body — inferred for complex type
=> Created($"/products/{dto.Id}", dto);
// Form data:
[HttpPost("upload")]
public IActionResult Upload(
[FromForm] string name,
IFormFile file) { ... }
[ApiController] inference rules:
- Complex types →
[FromBody] IFormFile/IFormFileCollection→[FromForm]- Route parameter matches parameter name →
[FromRoute] - Everything else →
[FromQuery]
For minimal APIs, parameters are bound the same way with additional support for direct DI injection:
app.MapGet("/products/{id}", (int id, IProductRepo repo) =>
repo.Find(id)); // id from route, repo from DI
Rule of thumb: Use [ApiController] to get automatic binding inference. Add
explicit [From*] attributes when you need to override the default or when
working without [ApiController].
When multiple routes match a request with equal specificity, ASP.NET Core throws
an AmbiguousMatchException at runtime (not build time). You must resolve ambiguity
by making routes more specific or by ordering them.
// Ambiguous: both match GET /api/products/5
[HttpGet("{id}")]
public IActionResult GetById(string id) { ... }
[HttpGet("{name}")]
public IActionResult GetByName(string name) { ... }
// ↑ AmbiguousMatchException — the runtime can't distinguish these
// Fix 1: use route constraints to differentiate:
[HttpGet("{id:int}")] // only matches integers → /products/5
public IActionResult GetById(int id) { ... }
[HttpGet("{name:alpha}")] // only matches letters → /products/widget
public IActionResult GetByName(string name) { ... }
// Fix 2: use different path templates:
[HttpGet("by-id/{id:int}")]
public IActionResult GetById(int id) { ... }
[HttpGet("by-name/{name}")]
public IActionResult GetByName(string name) { ... }
Conventional routing processes routes in registration order — first match wins — so ordering matters there:
// More specific pattern FIRST:
app.MapControllerRoute("specific", "products/featured", ...);
app.MapControllerRoute("default", "products/{id}", ...);
Rule of thumb: Design routes to be unambiguous using constraints or distinct templates. Rely on registration order only as a last resort — it is implicit and easy to break when adding new routes.
A fallback route is matched only when no other endpoint matches the request.
It is commonly used in Single-Page Applications to serve index.html for every
unmatched path so client-side routing (React Router, Vue Router) can take over.
// MapFallback — minimal API style:
app.MapControllers(); // controller endpoints matched first
// MapFallbackToFile: serves a static file for any unmatched path
app.MapFallbackToFile("index.html"); // SPAs: catch all and serve the shell
// MapFallbackToController: forwards unmatched routes to a controller action
app.MapFallbackToController("Index", "Home");
// Any unmatched request → HomeController.Index
// MapFallback with a handler:
app.MapFallback(async ctx =>
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new
{
error = "Route not found",
path = ctx.Request.Path.Value
});
});
// Fallback for a specific prefix only:
app.MapFallback("/app/{**slug}", async ctx =>
await ctx.Response.WriteAsync("SPA shell"));
// /app/dashboard, /app/settings → all served by SPA shell
// Other paths → normal 404
Fallbacks have lower priority than any other endpoint, including wildcard routes, so they never shadow real endpoints.
Rule of thumb: Use MapFallbackToFile("index.html") for SPA hosting. Use
MapFallback with a custom handler to return a structured JSON 404 for APIs.
Never use a catch-all route constraint ({**}) on a real controller action as a
substitute — use the proper fallback API.
Endpoint metadata is information attached to a matched endpoint at routing time.
It includes attributes ([Authorize], [AllowAnonymous], [RequireCors], OpenAPI
tags, etc.) and custom data added with WithMetadata. Middleware reads metadata from
IEndpointFeature to make decisions without inspecting the URL.
// Attach metadata to a minimal API endpoint:
app.MapGet("/orders", GetOrders)
.RequireAuthorization("AdminOnly") // adds AuthorizeAttribute metadata
.WithName("get-orders") // adds IEndpointNameMetadata
.WithTags("Orders") // adds ITagsMetadata (OpenAPI)
.WithMetadata(new AuditAttribute()); // custom metadata
// Middleware reading endpoint metadata after UseRouting:
app.UseRouting(); // route matching happens here — metadata is now available
app.Use(async (ctx, next) =>
{
var endpoint = ctx.GetEndpoint();
if (endpoint is not null)
{
// Check for a custom audit attribute on the matched endpoint:
var audit = endpoint.Metadata.GetMetadata<AuditAttribute>();
if (audit is not null)
await AuditLogAsync(ctx.Request.Path);
}
await next(ctx);
});
app.UseAuthorization(); // reads AuthorizeAttribute metadata set by UseRouting
app.MapControllers();
Without UseRouting running first, ctx.GetEndpoint() returns null because no
route has been matched yet.
Rule of thumb: Place any middleware that reads endpoint metadata after
UseRouting and before MapControllers. This is the window where the endpoint
is known but not yet executed.
Endpoint routing uses a precedence algorithm to rank routes, not simple registration order. Literal segments rank higher than parameters, and constrained parameters rank higher than unconstrained ones. Registration order is used only as a tiebreaker when two routes are equally specific.
// Endpoint routing precedence examples (higher beats lower):
// 1. Literal segment: /products/featured (most specific)
// 2. Constrained param: /products/{id:int}
// 3. Unconstrained param:/products/{id}
// 4. Catch-all: /products/{**rest} (least specific)
// All registered in any order — routing picks correctly:
app.MapGet("/products/featured", GetFeatured); // literal — wins for /products/featured
app.MapGet("/products/{id:int}", GetById); // constrained — wins for /products/5
app.MapGet("/products/{slug}", GetBySlug); // unconstrained — wins for /products/widget
app.MapGet("/products/{**rest}", GetSubPath); // catch-all — last resort
// Explicit Order property to force rank when needed:
app.MapGet("/debug", DebugHandler)
.WithOrder(-1); // negative = higher priority; runs before default (0)
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Conventional routes matched after attribute routes by default
For conventional routes, registration order matters within the same specificity tier — register more specific patterns first:
// Specific conventional pattern must come before generic one:
app.MapControllerRoute("blog", "blog/{year:int}/{slug}", new { controller = "Blog", action = "Post" });
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
Rule of thumb: Trust endpoint routing's precedence algorithm for attribute
routes — it handles most cases without explicit ordering. Add .WithOrder() only
when you have a genuine tie that the algorithm cannot break. For conventional
routes, always register more specific patterns first.
Minimal APIs use extension methods from Microsoft.AspNetCore.OpenApi (.NET 9) or
Swashbuckle to attach OpenAPI metadata — names, descriptions, tags, response
types, and operation IDs — directly to endpoint definitions.
// Install: dotnet add package Microsoft.AspNetCore.OpenApi (.NET 9+)
builder.Services.AddOpenApi(); // registers OpenAPI document generation
app.MapOpenApi(); // exposes /openapi/v1.json
// Attach metadata to individual endpoints:
app.MapGet("/products/{id:int}", async (int id, IProductRepo repo) =>
await repo.FindAsync(id) is Product p ? Results.Ok(p) : Results.NotFound())
.WithName("GetProduct") // operation id for link generation
.WithSummary("Get a product by ID") // short description in Swagger UI
.WithDescription("Returns the product matching the given integer ID, or 404.")
.WithTags("Products") // groups endpoints in Swagger UI
.Produces<Product>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
app.MapPost("/products", async (CreateProductDto dto, IProductRepo repo) =>
{
var product = await repo.CreateAsync(dto);
return Results.CreatedAtRoute("GetProduct", new { id = product.Id }, product);
})
.WithName("CreateProduct")
.WithTags("Products")
.Accepts<CreateProductDto>("application/json") // documents request body type
.Produces<Product>(StatusCodes.Status201Created)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest);
// Apply metadata to a whole group:
var v1 = app.MapGroup("/api/v1")
.WithTags("v1")
.WithOpenApi(); // enables OpenAPI for all endpoints in the group
Rule of thumb: Always attach .WithName, .WithTags, .Produces<T>, and
.WithSummary to every public endpoint. Accurate metadata drives Swagger UI,
client SDK generation, and integration test contracts — treat it as part of the
endpoint definition, not an afterthought.
More ASP.NET Core interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.