DEV Community

Naveed Ausaf
Naveed Ausaf

Posted on

Patterns for Routing in ASP.NET Minimal APIs

In ASP.NET Core, Routing is the mechanism that connects an incoming HTTP request to the code responsible for handling it. It performs two basic functions:

  • Routing: maps URLs in incoming HTTP requests to endpoint handlers that would handle those requests.
  • Link Generation: given a handler, generate a URL (that may be used to invoke that handler in the future). This, in a sense is the opposite of Routing.

Minimal APIs are great because they get rid of the boilerplate and give you a great deal of flexibility in how to construct your API. This flexibility is also their downside and needs to be harnessed with clear patterns for organising code.

This post gives a few patterns for Routing that would help to keep the code of your ASP.NET Minimal API projects well-organised.

You can skip the intro to routing and go straight to the patterns

How Routing Works in ASP.NET

To understand routing, we have to look at two distinct phases in an ASP.NET application: Startup and Runtime.

1. The Startup Phase: Registration

During application startup (usually in Program.cs), you register your endpoints, mapping a route template (like /products/{id}) to a handler (a delegate or method).

var app = builder.Build();

// set up middleware pipeline with `app.UseXXX` calls

// register endpoint handlers with `app.MapXXX` calls
app.MapGet("/products/{id}", (int productId) => {
  // find and return product with given `productId`
}).WithName("get-product");

app.MapPost("/products", (CreateProductArgs product) => {
  // create product in database with details given in `product`
}).WithName("create-product");

// start the app
app.Run();
Enter fullscreen mode Exit fullscreen mode

Optionally, you can also register middlewares using app.UseXXX() methods - these intercept your request and potentially process, or alter it, until it reaches a handler.

Registering endpoint handlers like this makes Program.cs large and messy. Controlling this "bloat" is what most of the patterns in this post are about.

2. The Runtime Phase: The Middleware Pipeline

When a request hits your server, it travels through the Middleware Pipeline.

The two key middlewares involved in routing are the Routing Middleware and the Endpoint Middleware:

  1. RoutingMiddleware: When a request comes in, this middleware matches the URL to a registered route template. It doesn't execute the code yet; it simply selects the "Endpoint" - the handler registered for the matched route template - and attaches it to the HttpContext.
    If you don't call app.UseRouting() in Program.cs, this would be added automatically as the first middleware in the pipeline when you call app.Run().

  2. The "In-Between": Any middleware placed between these two, like authorization and authentication middlewares, can now see which handler is about to be called and make decisions based on that, including returning a response that short-circuits the rest of the pipeline.

  3. EndpointMiddleware: This is the final stop. It takes the handler stored in the HttpContext, performs Model Binding - mapping request contents such as URL parameters like {id} and request body JSON data to handler method's arguments - and executes the handler.
    This is the last middleware in the pipeline and added automatically if you didn't call app.UseEndpoints().

The response then travels back through the middleware pipeline in reverse order.

For more details, see the MS Docs on Routing.

Sample Code

Patterns 1 - 3 are illustrated in a .NET minimal API project in the sample code repo on GitHub.

Pattern 4 is not part of the sample code but is quite straightforward.

Pattern 1: Handlers class

The Pattern: Move your route registrations and handlers out of Program.cs and into dedicated service-specific static classes each of which contains the endpoint handlers for a service or group and provides a public MapRoutes method to register them with ASP.NET.

Implementation

In a REST API a group would consist of all handlers that map to the same route such as /products and provide operations for the resource at that route, e.g. Products, but at different HTTP verbs (POST, GET etc.) and/or at different route segments (such as /products/{id} or /products).

We would move all handlers for /product out of Program.cs and into a Handlers class like this:

public class ProductHandlers
{
    public static class HandlerNames
    {
        public const string GetProduct = "get-product";
        public const string CreateProduct = "create-product";

    }

    public const string RoutePrefix = "/products";

    internal static RouteGroupBuilder MapRoutes(RouteGroupBuilder baseRouteGroup)
    {
        var routeBuilder = baseRouteGroup.MapGroup(RoutePrefix);

        routeBuilder.MapPost("/", HandleCreateProduct).WithName(HandlerNames.CreateProduct);

        routeBuilder.MapGet("/{id}", HandleGetProduct).WithName(HandlerNames.GetProduct);

        return routeBuilder;

    }

    internal static async Task<IResult> HandleGetProduct(int id, IProductService productService) { 
        /* handler logic */ 
    }

    internal static async Task<IResult> HandleCreateProduct(CreateProductArgs product, IProductService productService, LinkGenerator linkGen) {
        /* handler logic */
    }

}
Enter fullscreen mode Exit fullscreen mode

Then in Program.cs, create a RouteGroupBuilder for the base URL of the API, then call the static MapRoutes method in the Handlers class to register all the handlers contained in the class:

// In Program.cs
var app = builder.Build();

RouteGroupBuilder v1ApiRouteGroup = app.MapGroup("/v1");

ProductHandlers.MapRoutes(v1ApiRouteGroup);
Enter fullscreen mode Exit fullscreen mode

This is what the Handlers class contains:

  • A HandleXXX method for each endpoint handler

  • A nested static class HandlerNames
    This contains string constants for handler names that are provided to .WithName call that is chained to a .MapXXX call for handler registration.

    public static class HandlerNames
    {
        public const string GetProduct = "get-product";
        public const string CreateProduct = "create-product";
    
    }
    
  • Constant RoutePrefix. This is the common prefix of URLs of handlers in the group:

    public const string RoutePrefix = "/products";
    

    Being public allows it to be used in unit- and integration tests.

  • A MapRoutes static method that does the following:

    • takes a RouteGroupBuilder baseRouteGroup argument that represents the base URL of the API - e.g. / or, if your API versioning is based on base URL prefixes for major version numbers, then something like /v1 as used above
    • Registers all handlers in the class at RoutePrefix relative to the baseRouteGroup
    • Declares a name for each handler that is unique among all handlers registered in the API app

    In the example above, MapRoutes registers two handler methods, HandleCreateProduct and HandleGetProduct at routes /v1/products and /v1/products/{id}, with respective names create-product and get-product that are unique among all handlers registered in the API application.

Benefits

  • Clean Program.cs: As your app grows, Program.cs can quickly become a 1,000-line "god file." This keeps it focused on high-level configuration by moving both the handlers themselves and the code for their registration out of it.

  • Cohesion: All handlers for a service or a REST are now to be found in the one place together with their registration details (including their names and the routes at which they map).

  • Open API metadata: MapRoutes in the Handlers class is the perfect place to chain .WithTags(), .WithSummary() and other extension methods to provide Open API metadata on RouteGroupBuilder for the route prefix for the whole group of handlers and on handler registrations themselves.
    see example of this in the sample repo.

  • The Clean Architecture Advantage: If your API follows Clean Architecture, in particular if you separate:

    • protocol specific Interface Adapters such as the two HTTP/REST endpoint handlers above
    • from the business logic of the operations which the code above hints is contained in the injected IProductService implementation.

    then your endpoints handlers would only be thin HTTP/REST wrappers over the more substantial operation logic contained in the actual business logic classes. They only handle concerns like:

    • identifying routes at which requests are to be received (a REST concern)
    • returning appropriate HTTP codes such as 2xx in case of success and 4xx or 5xx in case of errors (an HTTP concern)
    • sending back data in a format that API callers can understand e.g. JSON (arguably a REST concern)
    • in case of errors, translating exceptions thrown by business logic services into IETF Problem Details responses to send back to the API caller (arguably a REST concern)
    • describing themselves for OpenAPI spec generation (see the section below).

    In this case it may make more sense for you to collect all of the endpoint handlers for a particular functionality or REST resource - such as all the handlers that invoke business logic that pertains to Products or Customers - in a single class (e.g. called ProductHandlers or CustomerHandlers), rather than have a separate file for each endpoint handler as per the REPR pattern.

    Of course if your endpoint handlers get too big or too numerous, you can still move them out into individual files as per the REPR pattern.

Pattern 2: Name Handlers for Reverse Routing

The Pattern: Never hardcode URLs when returning "Created" or "Redirect" results. Instead, use the LinkGenerator to resolve URLs by the handler's name.

Implementation

private static async Task<Created> HandleCreateProduct(CreateProductArgs product, IProductService productService, LinkGenerator linkGen)
{
    var id = await productService.Create(product);
    // Path-based generation is safer than URI-based
    // HandlerNames.GetProduct const has value "get-product"
    var path = linkGen.GetPathByName(HandlerNames.GetProduct, new { id });
    return TypedResults.Created(path);
}
Enter fullscreen mode Exit fullscreen mode

Suppose the id of the created product is 6f0ce3bd-cd86-425d-801a-d2c3e313cecf, then the returned URL would be:

/products/6f0ce3bd-cd86-425d-801a-d2c3e313cecf

You can see in MapRoutes above how this handler is registered (in the usual way).

The key here is the LinkGenerator that is resolved from DI container by the model binder that is invoked by the EndpointMiddleware and injected into the handler.

Its GetPathByName method computes a URL to the handler with unique name get-product, with first route parameter ({id}, see handler registration for get-product handler in MapRoutes above) set to the id of the newly created Product returned by IProductService.Create.

Benefits

  • Refactor-Friendliness: If you change your route template from /product/{id} to /catalog/{id}, you don’t have to hunt through your code to update string URLs.

  • Decoupling: Handlers don't need to know the structure of the rest of the app's URL space.

  • Security: By using GetPathByName instead of GetUriByName, you prevent Host Header attacks. GetPath returns a relative path (e.g., /product/12), whereas GetUri includes the domain, which can be manipulated if your server isn't strictly configured to filter host headers.
    In fact, not relying on the Host header value anywhere in your application code - the base URL at which the client originally sent the request - is one of the easiest ways of preventing Host Header attacks.
    This is also the reason why I do not use TypedResults.CreatedAtRoute() convenience method which does the same thing as TypedResults.Created() but does not required you to first have LinkGenerator injected and call some GetPathXXX() method on it yourself; it uses the LinkGenerator behind the scenes so you don't have to. The problem is it returns an absolute URI e.g. https://www.example.com/v1/products/6f0ce3bd-cd86-425d-801a-d2c3e313cecf rather than the root-relative URI that the method shown above returns which is safer.

Pattern 3: Routing without Validation

The Pattern: Keep route constraints (e.g., {id:int}) to a minimum and avoid using them for business logic validation.

Benefits

Route constraints are for discovering the correct endpoint. If a constraint fails, ASP.NET Core returns a 404 Not Found instead of running your handler.

However, if the ID exists but is simply invalid (e.g., a negative number), you still want your endpoint handler to run when it could send back a helpful 400 Bad Request or 422 Unprocessable Content response with a descriptive message or JSON to describe the error in more detail.

Better yet, validate the request with a library such as Fluent Validation or .NET's built-in validation facilities (made automatic in .NET 10 minimal APIs) return the validation error in IETF Problem Details format. I have an upcoming post on the topic. If you follow me, you would get notified!

Pattern 4: Prefer the "Double Star" Catch-all

The Pattern: When capturing a file path or a multi-segment URL, use the ** prefix (e.g., /{**slug}).

Benefits

While both * and ** capture the remainder of a URL in a route template, they handle "Reverse Routing" differently.

  • A single * will URL-encode forward slashes (turning / into %2F).

  • A double ** keeps the slashes as they are. If you are generating a link to a file path, you almost certainly want the latter.

Conclusion

Routing in Minimal APIs is deceptively simple, but by moving logic out of Program.cs and leaning on LinkGenerator, you create an API that is both easier to maintain and more secure by default.

Top comments (0)