Routing
An API’s URLs are its public contract — once mobile apps use them, they are hard to change. Attribute routing lets you design them precisely: /api/categories/3/products, /api/products/by-sku/MOUSE-1, and links that are generated, never hand-written.
On this page · 6 min · Beginner
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Beginner
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
Routes are the counter numbers: api/products/{id} sends the request to the right window with the product number attached.
- [Route("api/[controller]")]
- Base route from the controller name.
- {id:int}
- Route parameter with a constraint.
- Minimal APIs
- app.MapGet("/path", handler).
Learning objectives#
- Define routes with [Route] and HTTP method templates
- Use tokens, constraints and optional parameters
- Model nested resources
- Generate URLs with named routes and LinkGenerator
Explanation#
API controllers use attribute routing: [Route("api/[controller]")] on the class and [HttpGet("{id:int}")] on actions combine into /api/products/{id}. [controller] and [action] tokens are replaced by names.
Constraints restrict matches: {id:int}, {sku:regex(^[A-Z]+-\d+$)}, {page:min(1)}, {slug:length(3,60)}. A failed constraint means the route does not match (404), so bad ids never reach your code.
Nested resources reflect ownership: [Route("api/categories/{categoryId:int}/products")]. Keep nesting to one level.
Give routes names ([HttpGet("{id:int}", Name = "GetProduct")]) and use CreatedAtRoute or LinkGenerator.GetPathByName to build URLs — they stay correct when templates change.
Minimal APIs use the same templates and constraints: app.MapGroup("/api/categories/{categoryId:int}/products").MapGet("/", …).
Syntax#
[Route("api/categories/{categoryId:int}/products")]
public class CategoryProductsController : ControllerBase
{
[HttpGet] … // GET /api/categories/3/products
[HttpGet("{id:int}", Name = "GetCategoryProduct")] … // GET /api/categories/3/products/7
}
Code example: Constraints, nested resources and generated links#
// Program.cs
using System.Net.Http.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // keep the demo output clean
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5206");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5206") };
string[] paths = ["/api/products/2", "/api/products/abc", "/api/products/by-sku/HUB-2", "/api/products/by-sku/hub",
"/api/categories/1/products", "/api/categories/1/products/2", "/api/categories/2/products/2"];
foreach (var path in paths)
{
var response = await http.GetAsync(path);
Console.WriteLine(quot;GET {path,-30} {(int)response.StatusCode} {(response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : "")}".TrimEnd());
}
var created = await http.PostAsJsonAsync("/api/categories/1/products", new { name = "Mouse Pad" });
Console.WriteLine(quot;POST {"/api/categories/1/products",-30} {(int)created.StatusCode} Location: {created.Headers.Location}");
await app.StopAsync();
// Controllers/Catalog.cs
public record ProductDto(int Id, int CategoryId, string Sku, string Name);
public static class Catalog
{
public static readonly List<ProductDto> Products =
[new(1, 1, "MOUSE-1", "Wireless Mouse"), new(2, 1, "HUB-2", "USB-C Hub"), new(3, 2, "SPK-3", "Bluetooth Speaker")];
}
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")] // /api/products/2 (but not /abc)
public ActionResult<ProductDto> Get(int id) =>
Catalog.Products.FirstOrDefault(p => p.Id == id) is { } p ? p : NotFound();
[HttpGet("by-sku/{sku:regex(^[[A-Z]]+-\\d+$)}")] // /api/products/by-sku/HUB-2
public ActionResult<ProductDto> GetBySku(string sku) =>
Catalog.Products.FirstOrDefault(p => p.Sku == sku) is { } p ? p : NotFound();
}
// Controllers/CategoryProductsController.cs
using Microsoft.AspNetCore.Mvc;
public record NewProductDto(string Name);
[ApiController]
[Route("api/categories/{categoryId:int}/products")] // nested resource
public class CategoryProductsController : ControllerBase
{
[HttpGet]
public IEnumerable<string> GetAll(int categoryId) =>
Catalog.Products.Where(p => p.CategoryId == categoryId).Select(p => p.Name);
[HttpGet("{id:int}", Name = "GetCategoryProduct")]
public ActionResult<ProductDto> Get(int categoryId, int id) =>
Catalog.Products.FirstOrDefault(p => p.Id == id && p.CategoryId == categoryId) is { } p ? p : NotFound();
[HttpPost]
public IActionResult Create(int categoryId, NewProductDto dto)
{
var product = new ProductDto(Catalog.Products.Max(p => p.Id) + 1, categoryId, quot;NEW-{Catalog.Products.Count + 1}", dto.Name);
Catalog.Products.Add(product);
return CreatedAtRoute("GetCategoryProduct", new { categoryId, id = product.Id }, product);
}
}
GET /api/products/2 200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET /api/products/abc 404
GET /api/products/by-sku/HUB-2 200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET /api/products/by-sku/hub 404
GET /api/categories/1/products 200 ["Wireless Mouse","USB-C Hub"]
GET /api/categories/1/products/2 200 {"id":2,"categoryId":1,"sku":"HUB-2","name":"USB-C Hub"}
GET /api/categories/2/products/2 404
POST /api/categories/1/products 201 Location: http://127.0.0.1:5206/api/categories/1/products/4This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- {id:int} matches /api/products/2 but not /abc, which returns 404 before any code runs.
- The regex constraint only accepts SKUs like HUB-2; in route templates, [ and ] must be doubled ([[A-Z]]) and the backslash escaped.
- The nested controller reads categoryId from the URL for every action.
- Product 2 belongs to category 1, so /api/categories/2/products/2 is a 404 — nested lookups must check the parent too.
- CreatedAtRoute uses the route name to build the Location header for the new product.
Real-world use case#
ShopSmart’s public URLs follow a reviewed design: /api/products/{id}, /api/products/by-sku/{sku}, /api/categories/{id}/products and /api/orders/{id}/lines. Every Location header and HATEOAS-style link is generated from route names.
Common mistakes#
In route templates, [ ] { } are special. Write [[A-Z]] for a character class and escape backslashes.
Finding product 2 by id alone makes /api/categories/9/products/2 return it — misleading at best, a data leak at worst.
Constraints decide matching (404). Real validation (ranges, business rules) belongs in DTO validation (400/422).
Best practices#
- Design URLs before coding
- Type constraints on every id
- Named routes + CreatedAtRoute
- One level of nesting
Remember this#
[Route("api/[controller]")] + [HttpGet("{id:int}")].
Constraint fails → 404 (route does not match).
Name routes; generate URLs.
Practice exercise#
Your task: Add GET /api/products/search/{term:minlength(2)} returning product names containing the term (case-insensitive).
Need a hint?
Show solution
// Controllers/SearchController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/products")]
public class SearchController : ControllerBase
{
private static readonly string[] Names = ["Wireless Mouse", "Mouse Pad", "USB-C Hub"];
[HttpGet("search/{term:minlength(2)}")]
public IEnumerable<string> Search(string term) =>
Names.Where(n => n.Contains(term, StringComparison.OrdinalIgnoreCase));
}
Mini quiz#
What happens when a request to /api/products/abc hits [HttpGet("{id:int}")]?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.