As your API evolves, breaking changes are inevitable. Introducing API versioning ensures that your clients continue to work without disruption while you introduce new features and improvements.
This guide covers how to implement API versioning in .NET Web API with practical examples, including a clean BaseApiController approach for centralized configuration.
Why API Versioning?
- Backward Compatibility: Old clients continue to work with previous versions.
- Smooth Upgrades: You can introduce new endpoints without breaking existing functionality.
- Clear Communication: Clients know which version they are using.
- Better Lifecycle Management: Easier to deprecate old versions gradually.
Step 1: Add Required NuGet Package
dotnet add package Microsoft.AspNetCore.Mvc.Versioning
This package provides tools to version your API by query string, header, or URL segment.
Step 2: Configure Versioning in Program.cs
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // Add API versioning builder.Services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = new Microsoft.AspNetCore.Mvc.ApiVersion(1, 0); options.ReportApiVersions = true; // Returns API versions in response headers }); var app = builder.Build(); app.MapControllers(); app.Run();
Step 3: Create a BaseApiController
A BaseApiController can hold common configuration, attributes, and helper methods for all versioned controllers.
using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/v{version:apiVersion}/[controller]")] public abstract class BaseApiController : ControllerBase { protected IActionResult ApiResponse(object data, int statusCode = 200) { return StatusCode(statusCode, new { Status = statusCode, Data = data, Version = HttpContext.GetRequestedApiVersion()?.ToString() }); } }
All your versioned controllers can now inherit from this base controller for consistent behavior.
Step 4: Versioning Controllers Using BaseApiController
[ApiVersion("1.0")] public class ProductsController : BaseApiController { [HttpGet] public IActionResult Get() => ApiResponse(new[] { "Product 1", "Product 2" }); } [ApiVersion("2.0")] public class ProductsV2Controller : BaseApiController { [HttpGet] public IActionResult Get() => ApiResponse(new[] { "Product A", "Product B", "Product C" }); }
This keeps your code DRY and centralizes versioning logic.
Step 5: Query String and Header-Based Versioning
Query string example:
GET /api/orders?api-version=1.0 GET /api/orders?api-version=2.0
Header-based configuration in Program.cs
:
builder.Services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; options.DefaultApiVersion = new ApiVersion(1, 0); options.ApiVersionReader = new HeaderApiVersionReader("x-api-version"); options.ReportApiVersions = true; });
Clients can now set the header:
x-api-version: 2.0
Step 6: Deprecating Versions
[ApiVersion("1.0", Deprecated = true)] public class OldController : BaseApiController { [HttpGet] public IActionResult Get() => ApiResponse("This version is deprecated"); }
Headers will indicate deprecation.
Step 7: Swagger Integration for Versioned APIs
builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo { Title = "API v1", Version = "v1" }); options.SwaggerDoc("v2", new Microsoft.OpenApi.Models.OpenApiInfo { Title = "API v2", Version = "v2" }); }); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1"); options.SwaggerEndpoint("/swagger/v2/swagger.json", "API v2"); });
Wrapping Up
Using a BaseApiController centralizes common logic, keeps controllers DRY, and makes managing API versioning cleaner. Combined with URL, query string, or header-based versioning and Swagger integration, this approach provides a flexible and maintainable API structure in .NET.
Top comments (5)
This is exactly what I needed! I've been wrestling with API versioning on a project where we kept breaking frontend integrations every time we updated the backend.
Your BaseApiController approach is brilliant - I wish I'd seen this three weeks ago when I was manually managing version headers across 15 different endpoints. Made that mistake once and spent a full day debugging why the frontend was getting inconsistent responses.
The coordination nightmare is real though. I learned this the hard way after our backend team deployed v2 while I was still coding against v1 - spent three hours wondering why my TypeScript types were completely wrong.
A few things that saved my sanity:
OpenAPI specs are your friend - Having explicit contracts makes it so much easier to see what's actually changing between versions. I can literally diff the spec files now.
Mock servers for both versions - This was game-changing. While the backend team was building v2, I could test my migration code against realistic data for both versions. No more "well, I think this field changed" guesswork.
TypeScript generation helps - Your approach would work perfectly with auto-generated types. I've been using tools that generate TypeScript from OpenAPI specs, and having version-specific types catches so many issues before integration.
The deprecated flag is clutch too - gives frontend teams time to plan migrations instead of surprise breaking changes.
What's your experience been with teams during version transitions? Do you run both versions simultaneously, or do more of a hard cutover?
Thanks so much for sharing your experience. this is gold! 🙏
Totally agree on OpenAPI specs and mock servers. They’re lifesavers during transitions. Having explicit contracts and being able to spin up realistic mocks makes it so much easier to keep frontend and backend in sync without endless “what changed?” Slack messages.
I’ve also used TypeScript generation from OpenAPI in a few projects and it’s exactly as you said catching mismatches at compile time instead of in production saves hours of debugging.
On version transitions, my experience has been to run both versions simultaneously for a while. Usually:
Keep v1 running as “stable” (sometimes marked deprecated).
Deploy v2 alongside it.
Give frontend teams a clear timeline (e.g. “v1 will be supported for 90 - 120 days”).
Only do a hard cutover when everyone confirms they’ve migrated.
That overlap period is crucial because, as you mentioned, without it you can easily end up in a coordination nightmare where one side is on v1 and the other is on v2.
Curious, when you ran into that 3-hour mismatch, did you end up versioning your TypeScript clients separately, or just updating everything in one go?
Thanks again a lot for sharing your experience 🙌, your point about OpenAPI specs really resonated with me. Based on your feedback, I went ahead and added a dedicated blog post that dives deeper into using OpenAPI as a contract, spec diffs, and versioned docs during API version transitions. Appreciate you bringing it up, it definitely made the article stronger! here it is
This is very useful. I also like the possibility to set it in Headers and integration with swagger. Thanks for sharing!
Thank you for your kind words. It gives me motivation to keep writing these articles. ♥️