Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Minimal APIsRoutingASP.NET Core.NETREST
Share:𝕏

Route Parameters

C#
// 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

C#
// 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:

C#
// 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

C#
// 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

C#
// {*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

C#
// 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

C#
// 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 version

Route Priority and Ambiguity

C#
// 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 Guid

Production issue I've seen: A team had /patients/{id} (no constraint) and /patients/active (literal "active"). Without a route constraint, GET /patients/active matched the first route and passed "active" as the id parameter — causing a FormatException when trying to parse "active" as a Guid. Adding :guid to {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 :guid and :int constraints to prevent format exceptions from invalid URL segments. Name routes for link generation in Results.Created(). Follow REST conventions: resource nouns in paths, HTTP verbs for actions, actions as sub-resource POSTs.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.