Routing in Minimal APIs — Patterns, Constraints, and Parameters
Master Minimal API routing: route parameters, query strings, route constraints, regex routes, catch-all segments, and the patterns that build a clean URL structure for REST APIs.
Route Parameters
// Route parameter — captured from URL path
app.MapGet("/patients/{id}", async (Guid id, AppDbContext db) =>
{
var patient = await db.Patients.FindAsync(id);
return patient is null ? Results.NotFound() : Results.Ok(patient.ToDto());
});
// Multiple route parameters
app.MapGet("/patients/{patientId}/prescriptions/{rxId}",
async (Guid patientId, Guid rxId, AppDbContext db) =>
{
var rx = await db.Prescriptions
.FirstOrDefaultAsync(r => r.PatientId == patientId && r.Id == rxId);
return rx is null ? Results.NotFound() : Results.Ok(rx.ToDto());
});
// Optional route parameter
app.MapGet("/patients/{id?}", async (Guid? id, AppDbContext db) =>
id is null ? await db.Patients.ToListAsync() : await db.Patients.FindAsync(id));Query String Parameters
// Query string — bound automatically from ?param=value
app.MapGet("/patients", async (
AppDbContext db,
string? department, // ?department=Cardiology
bool? isActive, // ?isActive=true
int page = 1, // ?page=2 (default: 1)
int pageSize = 20) => // ?pageSize=50 (default: 20)
{
var query = db.Patients.AsQueryable();
if (department is not null)
query = query.Where(p => p.Department == department);
if (isActive.HasValue)
query = query.Where(p => p.IsActive == isActive.Value);
return await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
});Route Constraints
Constrain what values a route parameter accepts:
// Built-in constraints
app.MapGet("/patients/{id:guid}", GetPatient); // Guid only
app.MapGet("/reports/{year:int}", GetReport); // int only
app.MapGet("/wards/{code:alpha}", GetWard); // letters only
app.MapGet("/mrn/{value:minlength(6):maxlength(20)}", GetByMRN);
app.MapGet("/priority/{level:range(1,5)}", GetByPriority); // 1-5
// Common constraints reference:
// :int, :long, :double, :decimal
// :bool
// :datetime, :guid
// :alpha (letters only), :regex(pattern)
// :min(n), :max(n), :range(min,max)
// :minlength(n), :maxlength(n), :length(min,max)
// :required (non-empty)Custom Route Constraints
// Custom constraint — only valid MRN format
public sealed class MRNConstraint : IRouteConstraint
{
public bool Match(HttpContext? ctx, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection direction)
{
if (!values.TryGetValue(routeKey, out var value) || value is null)
return false;
var mrn = value.ToString()!;
// MRN format: MRN-followed by digits
return System.Text.RegularExpressions.Regex.IsMatch(mrn, @"^MRN-\d{3,10}$");
}
}
// Register
builder.Services.AddRouting(options =>
options.ConstraintMap.Add("mrn", typeof(MRNConstraint)));
// Use
app.MapGet("/patients/by-mrn/{mrn:mrn}", GetByMRN);Catch-All Parameters
// {*remainder} — matches everything to the end of the URL
// Useful for file paths or slug-style routing
app.MapGet("/files/{*filepath}", async (string filepath) =>
{
// filepath = "reports/2026/05/patient-summary.pdf"
var fullPath = Path.Combine("uploads", filepath);
return File.Exists(fullPath)
? Results.File(fullPath)
: Results.NotFound();
});HTTP Method Constraints
// Each Map* method constrains the HTTP verb
app.MapGet ("/patients/{id}", GetPatient); // GET
app.MapPost ("/patients", CreatePatient); // POST
app.MapPut ("/patients/{id}", UpdatePatient); // PUT (full replace)
app.MapPatch ("/patients/{id}", PatchPatient); // PATCH (partial update)
app.MapDelete ("/patients/{id}", DeletePatient); // DELETE
// Map to multiple verbs
app.MapMethods("/patients/{id}", ["GET", "HEAD"], GetPatient);
// Any verb
app.Map("/patients/{id}", HandleAnyVerb);Named Routes and Link Generation
// Name routes for link generation
app.MapGet("/patients/{id:guid}", GetPatient)
.WithName("GetPatient");
app.MapPost("/patients", async (CreatePatientDto dto, LinkGenerator links) =>
{
// Create patient...
var patient = /* created */;
// Generate the URL for the created resource
var location = links.GetPathByName("GetPatient", new { id = patient.Id });
return Results.Created(location, new { id = patient.Id });
});URL Structure Conventions for REST APIs
Resource naming:
GET /patients → list
POST /patients → create
GET /patients/{id} → get one
PUT /patients/{id} → replace
PATCH /patients/{id} → partial update
DELETE /patients/{id} → delete
Nested resources:
GET /patients/{id}/prescriptions
POST /patients/{id}/prescriptions
GET /patients/{id}/prescriptions/{rxId}
Actions (non-CRUD):
POST /patients/{id}/discharge
POST /patients/{id}/transfer
POST /prescriptions/{id}/dispense
Version prefix:
GET /api/v1/patients → versioned API
GET /api/v2/patients → next versionRoute Priority and Ambiguity
// When routes overlap, more specific routes win
app.MapGet("/patients/search", SearchPatients); // more specific
app.MapGet("/patients/{id}", GetPatient); // more general
// "search" matches the first route, not the second
// GET /patients/search → SearchPatients (correct)
// GET /patients/123 → GetPatient (correct)
// With constraints, constraint specificity matters
app.MapGet("/patients/{id:int}", GetByIntId); // matches if id is int
app.MapGet("/patients/{id:guid}", GetByGuid); // matches if id is GuidProduction issue I've seen: A team had
/patients/{id}(no constraint) and/patients/active(literal "active"). Without a route constraint,GET /patients/activematched the first route and passed "active" as theidparameter — causing aFormatExceptionwhen trying to parse "active" as a Guid. Adding:guidto{id}routes fixes this immediately.
Red Flag / Green Answer
Red Flag: "We use query strings for everything, including the resource ID: GET /patients?id=123."
IDs in query strings are not RESTful and cannot use route-level caching or constraints. Use path parameters for resource identifiers:
GET /patients/123. Query strings are for optional filters, pagination, and search terms.
Green Answer:
Resource ID in path:
/patients/{id:guid}. Optional filters in query string:?department=Cardiology&isActive=true. Path represents what you want; query string represents how to filter it.
Key Takeaway
Minimal API routing: path segments define the resource structure, route constraints validate parameter types, query strings carry optional filters. Use
:guidand:intconstraints to prevent format exceptions from invalid URL segments. Name routes for link generation inResults.Created(). Follow REST conventions: resource nouns in paths, HTTP verbs for actions, actions as sub-resource POSTs.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.