Endpoint Creation & Routing
Define endpoints with lambda expressions and route patterns
Endpoint Creation & Routing in ASP.NET with .NET 10
Master endpoint creation and routing in ASP.NET with .NET 10 through free flashcards and practical coding exercises. This lesson covers minimal APIs, route patterns, HTTP verb attributes, route parameters, and route constraintsโessential concepts for building modern web applications and RESTful services.
Welcome ๐ป
Welcome to the world of endpoint creation and routing in ASP.NET! If you've ever wondered how a web application knows which code to execute when you visit a URL like /products/123 or submit a form to /api/orders, you're about to discover the magic behind it. Routing is the mechanism that maps incoming HTTP requests to specific handlers or endpoints in your application.
With .NET 10, Microsoft has refined and expanded routing capabilities, making it easier than ever to create clean, maintainable APIs using minimal APIs alongside traditional controller-based approaches. Whether you're building a microservice, a RESTful API, or a full-featured web application, understanding routing is fundamental to your success as an ASP.NET developer.
In this lesson, we'll explore how to create endpoints using both minimal APIs and controllers, define sophisticated route patterns, handle different HTTP methods, extract data from URLs, and apply constraints to ensure your routes behave exactly as intended. Let's dive in! ๐
Core Concepts ๐
What is Routing?
Routing is the process of matching an incoming HTTP request to a specific endpoint in your application. Think of it as a sophisticated postal system: just as mail gets delivered to the right address based on the address label, HTTP requests get "delivered" to the right code based on the URL and HTTP method.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ REQUEST ROUTING FLOW โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ฑ Client Request
"GET /api/products/123"
|
โ
๐ Routing Middleware
(Pattern Matching)
|
โโโโโโดโโโโโ
โ โ
โ
Match โ No Match
| |
| โ
| ๐ด 404 Not Found
|
โ
๐ผ Endpoint Handler
(Your Code Executes)
|
โ
๐ค Response
"{ id: 123, name: 'Widget' }"
|
โ
๐ฑ Client Receives Response
Minimal APIs vs Controller-Based Routing ๐ฏ
.NET 6+ introduced Minimal APIs, a streamlined way to create endpoints without the ceremony of controllers. .NET 10 continues to enhance this approach.
| Aspect | Minimal APIs | Controller-Based |
|---|---|---|
| Code Style | Functional, concise | Object-oriented, structured |
| Boilerplate | Minimal | More ceremony |
| Best For | Microservices, simple APIs | Large applications, complex logic |
| File Location | Program.cs | Controllers folder |
| Dependency Injection | Method parameters | Constructor injection |
Creating Endpoints with Minimal APIs ๐ง
In .NET 10, creating endpoints is remarkably simple. You use the WebApplication object to map HTTP methods to handlers:
Basic endpoint structure:
app.MapGet("/route", (parameters) => { /* handler code */ });
The routing middleware examines each incoming request and attempts to match it against registered routes in the order they were defined.
HTTP Verb Mapping ๐
ASP.NET provides dedicated methods for each HTTP verb:
| HTTP Method | Minimal API | Purpose | Example Use |
|---|---|---|---|
| GET | MapGet | Retrieve data | Fetch product list |
| POST | MapPost | Create new resource | Add new customer |
| PUT | MapPut | Update entire resource | Update user profile |
| PATCH | MapPatch | Partial update | Change email address |
| DELETE | MapDelete | Remove resource | Delete order |
Route Parameters ๐
Route parameters allow you to capture values from the URL. They're defined using curly braces {parameter} in the route pattern:
app.MapGet("/products/{id}", (int id) => { /* use id */ });
When a request comes in for /products/42, the value 42 is automatically extracted and passed to your handler as the id parameter.
Parameter binding is automatic for:
- Route parameters (from URL path)
- Query string parameters (from URL after
?) - Headers
- Request body (for POST/PUT)
- Services (dependency injection)
๐ก Tip: Parameter names in your handler must match the route template names (case-insensitive).
Route Constraints ๐ง
Route constraints ensure that route parameters meet specific criteria before the route matches. This prevents invalid data from reaching your handler:
| Constraint | Syntax | Example | Matches |
|---|---|---|---|
| int | {id:int} | /user/42 | Integers only |
| guid | {id:guid} | /order/{guid} | Valid GUIDs |
| min | {age:min(18)} | /adult/21 | โฅ 18 |
| max | {count:max(100)} | /items/50 | โค 100 |
| length | {code:length(5)} | /zip/12345 | Exactly 5 chars |
| regex | {ssn:regex(^\d{{3}}-\d{{2}}-\d{{4}}$)} | /ssn/123-45-6789 | Pattern match |
| alpha | {name:alpha} | /user/john | Letters only |
Optional and Default Parameters โ๏ธ
You can make route parameters optional or provide defaults:
Optional parameter (using ?):
app.MapGet("/search/{term?}", (string? term) => { /* term might be null */ });
Default value (using =):
app.MapGet("/page/{pageNumber:int=1}", (int pageNumber) => { /* defaults to 1 */ });
Route Patterns and Catch-All ๐ฃ
ASP.NET supports several advanced routing patterns:
Catch-all parameter (captures everything):
app.MapGet("/files/{*filepath}", (string filepath) => { /* filepath contains full path */ });
// Matches: /files/documents/2024/report.pdf
// filepath = "documents/2024/report.pdf"
Multiple parameters:
app.MapGet("/blog/{year:int}/{month:int}/{slug}",
(int year, int month, string slug) => { /* handle */ });
// Matches: /blog/2024/03/aspnet-routing
Query String Parameters ๐
Query string parameters (after ? in URL) are automatically bound:
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
{
// URL: /search?query=laptop&page=2&pageSize=50
// query = "laptop", page = 2, pageSize = 50
});
๐ก Tip: Make query parameters nullable or provide defaultsโthey might not be present in the URL!
Route Groups ๐ฆ
.NET 10 enhances route groups, allowing you to apply common prefixes and configuration to multiple endpoints:
var apiGroup = app.MapGroup("/api");
apiGroup.MapGet("/products", () => { /* GET /api/products */ });
apiGroup.MapPost("/products", () => { /* POST /api/products */ });
Controller-Based Routing ๐ฎ
For larger applications, controller-based routing provides better organization:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetProduct(int id)
{
// Handles: GET /api/products/123
}
[HttpPost]
public IActionResult CreateProduct([FromBody] Product product)
{
// Handles: POST /api/products
}
}
The [controller] token in the route template is replaced with the controller name (minus "Controller"), so ProductsController becomes products.
Route Precedence ๐
When multiple routes could match a request, ASP.NET uses these rules:
ROUTE PRECEDENCE (highest to lowest)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. Exact literal match โ
โ /products/special โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 2. Constrained parameters โ
โ /products/{id:int} โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 3. Unconstrained parameters โ
โ /products/{name} โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 4. Optional parameters โ
โ /products/{category?} โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 5. Catch-all โ
โ /products/{*rest} โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๏ธ Common Mistake: Defining routes in the wrong order can cause unexpected behavior. More specific routes should come before more general ones.
Endpoint Metadata and Filters ๐ท๏ธ
.NET 10 allows you to add metadata and filters to endpoints:
app.MapGet("/admin/users", () => { /* handler */ })
.RequireAuthorization()
.WithName("GetAllUsers")
.WithTags("admin", "users")
.Produces<List<User>>(StatusCodes.Status200OK);
This enables:
- Authorization requirements
- OpenAPI/Swagger documentation
- Endpoint naming (for URL generation)
- Response type documentation
Route Parameter Transformation ๐
You can transform route parameters automatically:
public class SlugifyParameterTransformer : IOutboundParameterTransformer
{
public string? TransformOutbound(object? value)
{
return value?.ToString()?.ToLowerInvariant();
}
}
This ensures URLs are consistently formatted (e.g., /api/products instead of /api/Products).
Testing Routes ๐งช
๐ง Try this: Test if your routes are registered correctly:
app.MapGet("/debug/routes", (IEnumerable<EndpointDataSource> endpointSources) =>
{
var endpoints = endpointSources.SelectMany(es => es.Endpoints);
return endpoints.Select(e => new
{
Name = e.DisplayName,
Pattern = (e as RouteEndpoint)?.RoutePattern.RawText
});
});
Visit /debug/routes to see all registered routes in your application!
Examples with Detailed Explanations ๐ก
Example 1: Building a Simple Product API
Let's create a complete product API with multiple endpoints:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// In-memory data store for demo
var products = new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 999.99m },
new Product { Id = 2, Name = "Mouse", Price = 29.99m },
new Product { Id = 3, Name = "Keyboard", Price = 79.99m }
};
// GET all products
app.MapGet("/api/products", () => Results.Ok(products));
// GET single product by ID with constraint
app.MapGet("/api/products/{id:int}", (int id) =>
{
var product = products.FirstOrDefault(p => p.Id == id);
return product is not null
? Results.Ok(product)
: Results.NotFound($"Product {id} not found");
});
// GET products with pagination
app.MapGet("/api/products/search", (int page = 1, int pageSize = 10) =>
{
var pagedProducts = products
.Skip((page - 1) * pageSize)
.Take(pageSize);
return Results.Ok(new { Page = page, PageSize = pageSize, Data = pagedProducts });
});
// POST create new product
app.MapPost("/api/products", (Product product) =>
{
product.Id = products.Max(p => p.Id) + 1;
products.Add(product);
return Results.Created($"/api/products/{product.Id}", product);
});
// PUT update product
app.MapPut("/api/products/{id:int}", (int id, Product updatedProduct) =>
{
var product = products.FirstOrDefault(p => p.Id == id);
if (product is null) return Results.NotFound();
product.Name = updatedProduct.Name;
product.Price = updatedProduct.Price;
return Results.Ok(product);
});
// DELETE product
app.MapDelete("/api/products/{id:int}", (int id) =>
{
var product = products.FirstOrDefault(p => p.Id == id);
if (product is null) return Results.NotFound();
products.Remove(product);
return Results.NoContent();
});
app.Run();
record Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Explanation:
- We use MapGet for retrieval, MapPost for creation, MapPut for updates, and MapDelete for removal
- The
{id:int}constraint ensures only numeric IDs match that route - Query parameters (
page,pageSize) have defaults, making them optional - We return appropriate HTTP status codes using
Resultshelpers - The
Createdresult includes the new resource's URI in the Location header
Example 2: Advanced Route Constraints and Patterns
Let's explore sophisticated routing scenarios:
var app = WebApplication.CreateBuilder(args).Build();
// Route with multiple constraints
app.MapGet("/orders/{year:int:min(2020):max(2030)}/{month:int:range(1,12)}/{day:int:range(1,31)}",
(int year, int month, int day) =>
{
var date = new DateTime(year, month, day);
return $"Orders for {date:yyyy-MM-dd}";
});
// Matches: /orders/2024/03/15
// Rejects: /orders/2019/03/15 (year too old)
// Rejects: /orders/2024/13/15 (invalid month)
// Route with regex constraint for product codes
app.MapGet("/products/{code:regex(^[A-Z]{{3}}-\\d{{4}}$)}", (string code) =>
{
return $"Product code: {code}";
});
// Matches: /products/ABC-1234
// Rejects: /products/AB-123 (wrong format)
// Catch-all route for file paths
app.MapGet("/files/{*filepath}", (string filepath) =>
{
// filepath might be: "documents/2024/report.pdf"
return $"Requested file: {filepath}";
});
// Matches: /files/documents/2024/report.pdf
// Optional parameter with default
app.MapGet("/catalog/{category?}", (string category = "all") =>
{
return $"Showing category: {category}";
});
// Matches: /catalog โ category = "all"
// Matches: /catalog/electronics โ category = "electronics"
// Complex route with multiple optional segments
app.MapGet("/blog/{year:int?}/{month:int?}/{slug?}",
(int? year, int? month, string? slug) =>
{
if (slug is not null)
return $"Post: {year}/{month}/{slug}";
if (month.HasValue)
return $"Posts from {year}/{month}";
if (year.HasValue)
return $"Posts from {year}";
return "All posts";
});
// Matches: /blog โ all posts
// Matches: /blog/2024 โ posts from 2024
// Matches: /blog/2024/03 โ posts from March 2024
// Matches: /blog/2024/03/routing-guide โ specific post
app.Run();
Explanation:
- Multiple constraints can be chained with colons:
{year:int:min(2020):max(2030)} - Regex constraints require escaping backslashes:
\\din C# string becomes\din regex - Catch-all parameters (
*) capture all remaining path segments - Optional parameters allow routes to match multiple URL patterns
- The handler logic adapts based on which parameters are present
Example 3: Using Route Groups for API Versioning
Route groups help organize related endpoints:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Version 1 API
var v1 = app.MapGroup("/api/v1")
.WithTags("v1")
.WithOpenApi();
v1.MapGet("/users", () => new[]
{
new { Id = 1, Name = "Alice" },
new { Id = 2, Name = "Bob" }
});
v1.MapGet("/users/{id:int}", (int id) =>
new { Id = id, Name = "User" + id });
// Version 2 API with enhanced response
var v2 = app.MapGroup("/api/v2")
.WithTags("v2")
.WithOpenApi();
v2.MapGet("/users", () => new[]
{
new { Id = 1, Name = "Alice", Email = "alice@example.com", Active = true },
new { Id = 2, Name = "Bob", Email = "bob@example.com", Active = true }
});
v2.MapGet("/users/{id:int}", (int id) =>
new { Id = id, Name = "User" + id, Email = $"user{id}@example.com", Active = true });
// Admin endpoints with authorization
var admin = app.MapGroup("/api/admin")
.RequireAuthorization("AdminPolicy")
.WithTags("admin");
admin.MapGet("/stats", () => new
{
TotalUsers = 150,
ActiveSessions = 42,
ServerUptime = TimeSpan.FromHours(720)
});
admin.MapDelete("/users/{id:int}", (int id) =>
{
// Delete user logic
return Results.NoContent();
});
app.Run();
Explanation:
- MapGroup creates a route prefix applied to all endpoints in that group
- Each group can have its own metadata (tags, authorization, etc.)
- This enables API versioning:
/api/v1/usersvs/api/v2/users - Groups make it easy to apply authorization to entire sections: all
/api/admin/*routes require authorization - WithTags helps organize endpoints in OpenAPI/Swagger documentation
Example 4: Complex Controller-Based Routing
For larger applications, controllers provide better structure:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
// GET api/orders
[HttpGet]
public IActionResult GetOrders([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
// Return paginated orders
return Ok(new { Page = page, PageSize = pageSize, Orders = new List<object>() });
}
// GET api/orders/5
[HttpGet("{id:int}")]
public IActionResult GetOrder(int id)
{
return Ok(new { Id = id, Total = 99.99 });
}
// GET api/orders/by-customer/john-doe
[HttpGet("by-customer/{customerSlug}")]
public IActionResult GetOrdersByCustomer(string customerSlug)
{
return Ok(new { Customer = customerSlug, Orders = new List<object>() });
}
// GET api/orders/2024/03
[HttpGet("{year:int:min(2020)}/{month:int:range(1,12)}")]
public IActionResult GetOrdersByDate(int year, int month)
{
return Ok(new { Year = year, Month = month, Orders = new List<object>() });
}
// POST api/orders
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderDto dto)
{
var newOrder = new { Id = 123, Total = dto.Total, Status = "pending" };
return CreatedAtAction(nameof(GetOrder), new { id = 123 }, newOrder);
}
// PUT api/orders/5/status
[HttpPut("{id:int}/status")]
public IActionResult UpdateOrderStatus(int id, [FromBody] UpdateStatusDto dto)
{
return Ok(new { Id = id, Status = dto.NewStatus });
}
// DELETE api/orders/5
[HttpDelete("{id:int}")]
public IActionResult DeleteOrder(int id)
{
return NoContent();
}
}
record CreateOrderDto(decimal Total, List<int> ProductIds);
record UpdateStatusDto(string NewStatus);
Explanation:
[Route("api/[controller]")]creates the base route/api/orders- Each action method uses HTTP verb attributes:
[HttpGet],[HttpPost], etc. - Route templates in attributes are relative to the controller route
[FromQuery]explicitly binds from query string,[FromBody]from request bodyCreatedAtActiongenerates a Location header pointing to the new resource- Constraints work identically in controller routes:
{id:int},{year:int:min(2020)}
Common Mistakes โ ๏ธ
1. Forgetting Route Constraints
// โ WRONG: Accepts any string, even non-numeric
app.MapGet("/products/{id}", (string id) =>
{
if (!int.TryParse(id, out var productId))
return Results.BadRequest("Invalid ID");
// ...
});
// โ
RIGHT: Only matches numeric IDs
app.MapGet("/products/{id:int}", (int id) =>
{
// id is guaranteed to be an integer
});
2. Route Order Issues
// โ WRONG: Catch-all defined first
app.MapGet("/products/{name}", (string name) => $"Product: {name}");
app.MapGet("/products/featured", () => "Featured products");
// "/products/featured" will match the first route with name="featured"!
// โ
RIGHT: Most specific routes first
app.MapGet("/products/featured", () => "Featured products");
app.MapGet("/products/{name}", (string name) => $"Product: {name}");
3. Missing Nullable Annotations for Optional Parameters
// โ WRONG: Runtime null warning if query param missing
app.MapGet("/search", (string query) => { /* query might be null! */ });
// โ
RIGHT: Explicitly nullable or with default
app.MapGet("/search", (string? query) => { /* proper null handling */ });
app.MapGet("/search", (string query = "all") => { /* has default */ });
4. Not Using Results Helpers
// โ WRONG: Returns wrong status code
app.MapGet("/users/{id:int}", (int id) =>
{
return null; // Returns 204 No Content instead of 404!
});
// โ
RIGHT: Use Results helpers
app.MapGet("/users/{id:int}", (int id) =>
{
return Results.NotFound($"User {id} not found");
});
5. Inconsistent Route Naming
// โ WRONG: Inconsistent naming
app.MapGet("/Products", () => { /* ... */ });
app.MapGet("/api/orders", () => { /* ... */ });
// โ
RIGHT: Consistent lowercase, clear hierarchy
app.MapGet("/api/products", () => { /* ... */ });
app.MapGet("/api/orders", () => { /* ... */ });
6. Not Validating Input
// โ WRONG: No validation
app.MapPost("/api/products", (Product product) =>
{
// What if product.Name is null or empty?
return Results.Created($"/api/products/{product.Id}", product);
});
// โ
RIGHT: Validate input
app.MapPost("/api/products", (Product product) =>
{
if (string.IsNullOrWhiteSpace(product.Name))
return Results.BadRequest("Product name is required");
if (product.Price <= 0)
return Results.BadRequest("Price must be positive");
return Results.Created($"/api/products/{product.Id}", product);
});
7. Overlapping Routes
// โ WRONG: Ambiguous routes
app.MapGet("/api/{resource}/{id:int}", (string resource, int id) => { });
app.MapGet("/api/products/{id:int}", (int id) => { });
// Second route will never match!
// โ
RIGHT: Specific routes or clear differentiation
app.MapGet("/api/products/{id:int}", (int id) => { });
app.MapGet("/api/orders/{id:int}", (int id) => { });
Key Takeaways ๐ฏ
๐น Routing maps HTTP requests to endpoints based on URL patterns and HTTP methods
๐น Minimal APIs (MapGet, MapPost, etc.) provide a concise way to define endpoints directly in Program.cs
๐น Route parameters {id} capture values from URLs and bind them to handler parameters
๐น Route constraints {id:int} ensure parameters meet specific criteria before the route matches
๐น Route order matters: more specific routes should be defined before general ones
๐น Results helpers (Results.Ok, Results.NotFound, etc.) return appropriate HTTP status codes
๐น Route groups organize related endpoints and apply common configuration
๐น Controller-based routing with attributes provides better structure for large applications
๐น Query parameters are automatically bound from the URL query string
๐น Always validate input and use nullable types for optional parameters
Further Study ๐
Official Microsoft Documentation - Routing in ASP.NET Core
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routingMinimal APIs Overview
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apisRoute Constraint Reference
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints
๐ Quick Reference Card: ASP.NET Routing
| Concept | Syntax | Example |
|---|---|---|
| Basic GET endpoint | app.MapGet(route, handler) | app.MapGet("/api/users", () => users) |
| Route parameter | {paramName} | "/users/{id}" |
| Integer constraint | {param:int} | "/products/{id:int}" |
| Range constraint | {param:range(min,max)} | "{age:range(18,100)}" |
| Optional parameter | {param?} | "/search/{term?}" |
| Default value | {param=value} | "{page:int=1}" |
| Catch-all | {*param} | "/files/{*path}" |
| POST endpoint | app.MapPost(route, handler) | app.MapPost("/api/users", CreateUser) |
| Route group | app.MapGroup(prefix) | app.MapGroup("/api/v1") |
| Success response | Results.Ok(data) | Results.Ok(users) |
| Created response | Results.Created(uri, data) | Results.Created($"/api/users/{id}", user) |
| Not found response | Results.NotFound() | Results.NotFound("User not found") |