API versioning in ASP.NET Core: Plan for v2 on day one
Most API projects start with v1 and never think about versioning until a breaking change forces their hand. By then, you're scrambling to support multiple versions simultaneously while clients panic about migrations. I've learned the hard way that API versioning in ASP.NET Core isn't a feature you bolt on later—it's an architectural decision that shapes how you organize code, deploy services, and communicate with your users.
This isn't about premature optimization. It's about building flexibility into your API design from day one, even if you don't implement multiple versions until genuinely needed. In this post, I'll walk through how to structure your versioning strategy, the tools available to you, and the practical trade-offs I've made shipping versioned APIs across e-commerce platforms, trading systems, and SaaS products.
Why versioning matters before you need it
I've joined projects at the point where they desperately needed versioning but had zero infrastructure for it. A single breaking change—renaming a field, removing an endpoint, changing a response structure—forces you to either maintain two codebases or anger half your user base.
The real problem isn't the first breaking change. It's the fifth one, when you're supporting v1, v2, and v3 simultaneously, each with slightly different business logic, validation rules, and database queries. Without a versioning plan from the start, your controller files balloon to 2,000 lines of conditional logic.
The good news: planning for versioning costs almost nothing upfront. It's mostly about naming conventions, folder structure, and deciding which versioning scheme fits your API. Implementation comes later, only when you actually need it.
ASP.NET Core versioning strategies: URL, header, and query approaches
ASP.NET Core doesn't ship with versioning baked in, but the community standard is Asp.Versioning (formerly Microsoft.AspNetCore.Mvc.Versioning). It supports three main approaches, each with different trade-offs.
URL versioning embeds the version directly in the route:
GET /api/v1/products
GET /api/v2/productsHeader versioning passes the version as a custom header:
GET /api/products
Header: Api-Version: 1.0Query versioning appends it as a query parameter:
GET /api/products?api-version=1.0There's also a hybrid approach where you support multiple schemes simultaneously, which I've done for larger APIs where different client types have different preferences.
Each approach has legitimate use cases. The choice often depends on your deployment model, client ecosystem, and debugging requirements. Later I'll explain why URL versioning wins for most scenarios, but first let's see what the setup looks like.
Setting up Asp.Versioning in your ASP.NET Core project
Start by installing the NuGet package:
Install-Package Asp.Versioning.Mvc.ApiExplorerThen wire it into your Program.cs:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("api-version"),
new QueryStringApiVersionReader("api-version")
);
})
.AddMvc()
.AddApiExplorer();
builder.Services.Configure<ApiExplorerOptions>(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});This setup lets you use all three versioning strategies, defaults to v1.0 if unspecified, and tells Swagger to generate separate documentation for each version.
Now you can decorate your controllers:
[ApiVersion("1.0")]
[ApiVersion("2.0", Deprecated = true)]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id, ApiVersion apiVersion)
{
if (apiVersion.MajorVersion == 1)
{
// Legacy response format
return Ok(new { id, name = "Widget", price = 9.99m });
}
// v2 includes additional fields
return Ok(new {
id,
name = "Widget",
price = 9.99m,
category = "General",
createdAt = DateTime.UtcNow
});
}
}This single controller handles both v1 and v2, with version-specific logic inside the methods. For complex APIs, you might separate versions into different controller files entirely.
URL segments (/v1/) are the easiest to debug
I've shipped APIs with all three versioning strategies, and URL versioning consistently wins for debugging and developer experience.
When a support ticket lands—"the API is returning the wrong data"—the first thing you ask is "which version are you hitting?" With URL versioning, it's immediately visible in the HTTP request. With header or query versioning, your developers have to ask for request logs or browser developer tools.
There's also a psychological benefit. Developers see /api/v2/products and instantly know there's a v1 they might need to reference. They can make educated guesses about backwards compatibility. With headers, the versioning becomes invisible, and developers often forget it exists entirely.
From a deployment perspective, URL versioning integrates cleanly with caching, CDN rules, and API gateway configurations. Many teams implement different rate limits or authentication rules per version, and it's trivial when the version is in the URL path.
The downside: URL versioning is slightly more verbose, and it ties the version to the URI structure. If you're writing a JavaScript client, you'll construct URLs like baseUrl + '/api/v' + apiVersion + '/products'. It's not elegant, but it works.
That said, if your API is consumed primarily by mobile apps with strict bandwidth constraints or you have internal requirements to keep URLs clean, header versioning is a solid alternative. I've used it successfully for microservices that talk to each other, where the version is typically set once in a configuration value.
Deprecating versions with sunset headers
At some point, you'll need to sunset an old version. Maybe it's been three years since v1 shipped, and 95% of clients have migrated to v2. You can't just kill it—there's always one customer on a five-year-old device still using it. You need to give them time and signal your intentions clearly.
HTTP Sunset header is the standard way to communicate this:
[ApiVersion("1.0", Deprecated = true)]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id)
{
// Asp.Versioning automatically includes deprecation in response headers
// but you can also set Sunset explicitly
Response.Headers.Add("Sunset", new DateTime(2025, 12, 31).ToUniversalTime().ToString("R"));
Response.Headers.Add("Deprecation", "true");
Response.Headers.Add("Link", "</api/v2/products/{id}>; rel=\"successor-version\"");
return Ok(new { id, name = "Widget" });
}
}The Sunset header tells clients when the version will no longer be supported. Deprecation: true signals that it's deprecated. Link with successor-version points to the newer endpoint.
In practice, I recommend a gradual deprecation timeline:
- Month 1: Add Deprecation and Sunset headers. Send emails to known consumers.
- Month 3: Log warnings for every request to deprecated versions. Alert the ops team about remaining traffic.
- Month 6: Start returning HTTP 410 Gone for deprecated versions, with instructions to migrate.
- Month 12: Remove the old version entirely.
This phased approach gives you visibility into who's still using old versions and lets you coordinate with their teams before hard cutoffs.
Additive changes never need a new version
Here's the insight that saves the most work: not every change requires a new API version.
Adding a new optional field to a response? That's backwards compatible. Clients that don't know about it will ignore it. Adding a new optional query parameter? Same thing. Clients that don't send it get the default behavior.
I've worked on APIs where teams bumped the version for every tiny addition. After six months, they had v1 through v7, each with subtle differences, and no clear reason why a client should migrate. That's not versioning—that's noise.
True breaking changes that require a new version:
- Removing a field from a response
- Changing the type of a field (string to object, for example)
- Renaming a field
- Removing an endpoint
- Changing the meaning or behavior of a field
- Making a previously optional parameter required
Everything else? Ship it in the current version. Your API contracts stay clean, and clients don't chase versions unnecessarily.
Here's an example of an additive change that doesn't need versioning:
// v1.0 response
{
"id": 1,
"name": "Widget"
}
// Still v1.0 after adding optional field
{
"id": 1,
"name": "Widget",
"category": "General" // New optional field, clients can ignore
}Compare that to a breaking change that definitely needs v2:
// v1.0 response
{
"id": 1,
"productName": "Widget"
}
// v2.0 response - field renamed, clients break
{
"id": 1,
"name": "Widget" // Renamed from productName
}The discipline to keep versions stable is harder than it sounds. Product managers want to call it v2 because it feels like progress. Developers want to clean up old field names. But every version you create is debt. You're committing to support it, test it, and document it for years.
Structuring your codebase for multiple versions
As your API grows, you'll want to organize code by version. I've tried a few approaches, and this is what works:
Controllers/
V1/
ProductsController.cs
OrdersController.cs
V2/
ProductsController.cs
OrdersController.cs
Services/
IProductService.cs
ProductServiceV1.cs
ProductServiceV2.cs
Models/
V1/
ProductDto.cs
V2/
ProductDto.csShared business logic lives outside version folders. Version-specific logic, DTOs, and controllers go inside version folders. This makes dependencies explicit: if you see code importing from V2, you know it's v2-only logic.
For simple APIs, you can keep everything in one controller with conditional logic. For complex ones with significantly different business logic per version, separate controllers are cleaner.
Common mistakes
Mistake 1: Creating versions without a clear breaking change. Resist the urge. If you're just reorganizing fields or adding optional parameters, ship it in the current version. Extra versions are technical debt.
Mistake 2: Supporting too many old versions simultaneously. I recommend supporting the current version plus one previous version. Three versions maximum in any mature API. This keeps maintenance burden reasonable and incentivizes clients to stay current.
Mistake 3: Forgetting to test backwards compatibility. When you deploy v2, run your v1 contract tests against it. Automated tests that verify each version's schema and expected behaviors save hours of debugging later.
Mistake 4: Not documenting version differences. Your API docs should clearly explain what changed between versions and why. Swagger/OpenAPI separates docs by version automatically, but you still need human-readable migration guides.
Mistake 5: Versioning query parameters or header contracts along with the URL version. Decide early: are you versioning the entire API contract, or just the response shape? Mixing levels of versioning confuses clients.
Key takeaways
- Plan for versioning on day one by choosing a strategy (URL, header, or query), but don't implement multiple versions until genuinely needed.
- URL versioning (/v1/, /v2/) is easiest to debug, cache, and reason about. It's the default choice for most public APIs.
- Use Asp.Versioning to standardize versioning logic rather than building it yourself. It handles headers, Swagger generation, and deprecation workflows automatically.
- Only create a new version when you make a true breaking change. Adding optional fields or endpoints is always backwards compatible and doesn't require versioning.
- Deprecate old versions explicitly with Sunset headers and a clear timeline. Give clients at least three to six months' notice before removing a version.
- Organize versioned code by version folders to keep logic separated and dependencies explicit.
- Support only the current version plus one previous version to keep maintenance costs sustainable.






Comments
Loading comments…