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:
- Inspect the incoming request for the
X-Api-Versionheader. - Based on the header’s value, prepend a version segment (e.g.,
/v1or/v2) to the request’s path. - If the header is missing, default to a specific version.
- 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:
- A client sends a request to
GET /products. - Our
ApiVersioningMiddlewareintercepts it. - It checks for the
X-Api-Versionheader. Let’s say the header is2. - The middleware changes the request path to
/v2/products. - The request continues down the pipeline.
- The ASP.NET Core routing engine sees the path
/v2/productsand finds the matching endpoint mapped to theGetProductsV2handler.
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
- ASP.NET Core Middleware Documentation - Official Microsoft documentation on creating and using middleware.
- ASP.NET API Versioning Library - The repository for the popular
Asp.Versioning.Httplibrary, a great alternative for more complex scenarios. - Martin Fowler on API Versioning - While not a direct article on versioning, his patterns often touch upon the principles of evolving APIs for different clients.