As APIs evolve, the need to introduce breaking changes becomes inevitable. The key challenge is to roll out these updates without disrupting existing clients who depend on the current contract. This is where API versioning comes in. While feature-rich libraries like Asp.Versioning.Http are excellent, understanding how to build your own versioning scheme with middleware gives you maximum control and a deeper appreciation for the ASP.NET Core pipeline.

In this post, we’ll explore a clean, middleware-based approach to API versioning using a custom request header. We’ll build a solution that inspects a header and transparently rewrites the request path to route to the correct endpoint implementation.

Why a Middleware Approach?

Dedicated versioning libraries are powerful, offering features like API explorer integration, version advertisement, and conventions. However, a custom middleware solution offers its own set of advantages:

  • Full Control: You define the exact logic for how a version is resolved. You can read it from a header, a claim, a query string, or even a combination of factors.
  • Lightweight: You add only the logic you need, without any extra overhead.
  • Decoupled: The versioning logic lives in one place (the middleware) rather than being scattered across controllers with attributes. This can lead to cleaner endpoint definitions.
  • Great for Learning: Building it yourself is a fantastic way to understand how the ASP.NET Core request pipeline and routing work together.

From the Field: While libraries are my go-to for large, complex projects, I’ve found the middleware pattern incredibly useful for internal services or APIs with very specific versioning rules. For instance, I once worked on a system where the API version was determined by a JWT claim issued to the client. A simple piece of middleware was the perfect, clean solution for this.

Setting Up Our Versioned API

Let’s start with a new ASP.NET Core Web API project. Our goal is to create a /products endpoint with two distinct versions:

  • V1: Returns a simple list of product names.
  • V2: Returns a more detailed list of products, including their price.

We’ll use a custom header, X-Api-Version, to let the client specify which version they want.

First, let’s define our simple models and handlers. We’ll use Minimal APIs for this example because they keep the focus on the routing and middleware logic.

In Program.cs, add the following:

// Models for our two versions
public record ProductV1(int Id, string Name);
public record ProductV2(int Id, string Name, decimal Price);

// Static data for demonstration
var productsV1 = new List<ProductV1>
{
    new(1, "Laptop"),
    new(2, "Keyboard")
};

var productsV2 = new List<ProductV2>
{
    new(1, "Laptop", 999.99m),
    new(2, "Keyboard", 75.50m)
};

// V1 Endpoint Logic
var GetProductsV1 = () => Results.Ok(productsV1);

// V2 Endpoint Logic
var GetProductsV2 = () => Results.Ok(productsV2);

Notice we have two separate handlers: GetProductsV1 and GetProductsV2. The core of our task is to route an incoming request to the correct handler based on the X-Api-Version header. We’ll achieve this by having our middleware rewrite the path and then mapping endpoints to these new, version-specific paths.

Building the Versioning Middleware

The middleware is where the magic happens. Its job is simple:

  1. Inspect the incoming request for the X-Api-Version header.
  2. Based on the header’s value, prepend a version segment (e.g., /v1 or /v2) to the request’s path.
  3. If the header is missing, default to a specific version.
  4. Pass the modified request to the next component in the pipeline.

Create a new file named ApiVersioningMiddleware.cs:

public class ApiVersioningMiddleware
{
    private readonly RequestDelegate _next;
    private const string VersionHeaderName = "X-Api-Version";

    public ApiVersioningMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Default to version 1 if the header is not provided
        var version = context.Request.Headers[VersionHeaderName].FirstOrDefault() ?? "1";

        // Rewrite the path to include the version
        // e.g., /products becomes /v1/products
        var originalPath = context.Request.Path;
        context.Request.Path = $"/v{version}{originalPath}";

        await _next(context);
    }
}

This middleware is concise and effective. It reads the header, defaults to "1" if it’s missing, and then manipulates the HttpContext.Request.Path property. This modification happens before the routing middleware runs, so routing will act upon the new path.

Wiring It All Up in Program.cs

Now, let’s integrate the middleware and map our endpoints to the versioned paths.

Update your Program.cs file:

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

// ... (Models and data from before)

// Register our custom middleware in the pipeline
// IMPORTANT: It must be registered before routing (app.UseRouting)
// and before endpoints are mapped (app.Map...).
// With the minimal API setup, placing it before mapping is sufficient.
app.UseMiddleware<ApiVersioningMiddleware>();

// Map endpoints to version-specific paths
app.MapGet("/v1/products", GetProductsV1);
app.MapGet("/v2/products", GetProductsV2);

app.Run();

// --- Handlers from before ---
IResult GetProductsV1() => Results.Ok(productsV1);
IResult GetProductsV2() => Results.Ok(productsV2);

// --- Models from before ---
public record ProductV1(int Id, string Name);
public record ProductV2(int Id, string Name, decimal Price);

// --- Data from before ---
var productsV1 = new List<ProductV1> { new(1, "Laptop"), new(2, "Keyboard") };
var productsV2 = new List<ProductV2> { new(1, "Laptop", 999.99m), new(2, "Keyboard", 75.50m) };

The request flow is now:

  1. A client sends a request to GET /products.
  2. Our ApiVersioningMiddleware intercepts it.
  3. It checks for the X-Api-Version header. Let’s say the header is 2.
  4. The middleware changes the request path to /v2/products.
  5. The request continues down the pipeline.
  6. The ASP.NET Core routing engine sees the path /v2/products and finds the matching endpoint mapped to the GetProductsV2 handler.

Testing the Implementation

You can test this with any API client like curl, Postman, or the Visual Studio Code REST Client extension.

Requesting V1 (or default): Send a request without the header, and you should get the V1 response because our middleware defaults to it.

curl http://localhost:5000/products

Response:

[
  {
    "id": 1,
    "name": "Laptop"
  },
  {
    "id": 2,
    "name": "Keyboard"
  }
]

Requesting V2: Now, send a request with the X-Api-Version header set to 2.

curl -H "X-Api-Version: 2" http://localhost:5000/products

Response:

[
  {
    "id": 1,
    "name": "Laptop",
    "price": 999.99
  },
  {
    "id": 2,
    "name": "Keyboard",
    "price": 75.5
  }
]

It works perfectly. The client interacts with a consistent URL (/products), and the versioning is handled behind the scenes based on the header.

Conclusion

Implementing API versioning with custom middleware offers a powerful and flexible alternative to using larger, convention-based libraries. By directly manipulating the request path, you can create a clean separation between your versioning logic and your endpoint definitions. This approach keeps your controllers or Minimal API handlers focused solely on business logic, while the middleware handles the cross-cutting concern of version resolution.

While this example is simple, you can easily extend the middleware to include more robust error handling for invalid versions, pull configuration from appsettings.json, or implement more complex version resolution strategies. For your next project, consider if this lightweight pattern fits your needs before reaching for a full-featured library.

References