Wednesday, October 7, 2026

ASP.NET Core UseRouting / UseEndpoints explained

ASP.NET Core engine is capable of using so called endpoints. The idea is to use the list of your endpoint paths to build a DFA matcher that matches endpoints as fast as possible, no matter if you have 5 or 5000 endpoints.

The official documentation explains how routes are defined.

The problem of understanding of how internals work is that there are two canonical calls, UseRouting and UseEndpoints and this has been simplified to MapXXX in Minimal API (where each call to Map actually registers an endpoint.

The example below demonstrates and explains when an endpoint is registered, when it's matched and what if you want to have routes that cannot be defined using the language but you just want to have a complete freedom of matching any Func<string, bool> to a request delegate.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Routing middleware sets the endpoint for current request
// If any other later middleware calls .GetEndpoint(),
// the selected endpoint will be available
app.UseRouting();

// An example middleware that can access the endpoint data
app.Use(async (context, next) =>
{
    Endpoint? matchedEndpoint = context.GetEndpoint();

    if (matchedEndpoint != null)
    {
        Console.WriteLine($"[log] matched endpoint: {matchedEndpoint.DisplayName}");

        // endpoint can have additional metadata
        var securityMetadata = matchedEndpoint.Metadata.GetMetadata<AccessLevelRestriction>();
        if (securityMetadata != null)
        {
            Console.WriteLine($"[log] Required access level: {securityMetadata.Level}");
        }
    }
    else
    {
        Console.WriteLine("[log] no endpoint matched for current url");
    }

    // go next, to UseEndpoints
    await next();
});

// Endpoint table definition
// UseEndpoints does two things
// * when called on app (app.UseEndpoints) once, at app start
//   it configures a list of known endpoints and their RequestDelegates
// * it also registers a middleware that runs at each request
//   and calls the RequestDelegate whenever a route is matched in current
//   request (by the middleware from UseRouting!)
#pragma warning disable ASP0014
app.UseEndpoints(endpoints =>
{
    // map path to RequestDelegate 
    endpoints.Map("/profile/{userId}", async context =>
    {
        // route matched by UseRouting left its RouteValues 
        var userId = context.Request.RouteValues["userId"];

        context.Response.ContentType = "text/plain; charset=utf-8";
        await context.Response.WriteAsync($"Welcome to the profile of a user ID: {userId}");
    })
    .WithDisplayName("UseProfileEndpoint")               // set name
    .WithMetadata(new AccessLevelRestriction(Level: 4)); // attach metadata
});

// ASP.NET uses a template language to define routes
// this language is optimized so that the DFA matcher is built to match routes as fast as possible
// (finite state automata)
// this template language allows fixed segments, slashes, curly brackets, variables, type constraints
// but it's not a mapping from Func<string, bool> to RequestDelegates
// there are definitely routes you can't match!
// But, a custom matcher from Func<string, bool> to RequestDelegates can be easily built
// Here is an example usage:
app.MapCustom(
    path => path.StartsWith("/cms", StringComparison.OrdinalIgnoreCase),
    async context =>
    {
        // no endpoint here, it's our custom matcher
        await context.Response.WriteAsync("The custom matcher matched this one!");
    });

// If neither an endpoint nor a custom route matched - default delegate
app.Run(async context =>
{
    context.Response.StatusCode = 404;
    await context.Response.WriteAsync("404 - nothing matched.");
});

app.Run();

public record AccessLevelRestriction(int Level);

public static class SimpleCustomMappingExtensions
{
    // The custom mapper maps Func<string, bool> to RequestDelegate
    public static IApplicationBuilder MapCustom(
        this IApplicationBuilder app,
        Func<string, bool> urlMatcher,
        RequestDelegate requestDelegate)
    {
        // just a middleware
        return app.Use(async (context, next) =>
        {
            var path = context.Request.Path.Value ?? string.Empty;

            // does it match
            if (urlMatcher(path))
            {
                // call the delegate and stop
                await requestDelegate(context);
                return;
            }
            else
            {
                // call next delegate
                await next();
            }
        });
    }
}

No comments: