Skip to content

Routing Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

ASP.NET Core routing interview questions — attribute vs conventional routing, route constraints, endpoint routing, link generation, and minimal APIs.

Read the in-depth guideRouting in ASP.NET Core(opens in new tab)
15 of 15

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.

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 ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel