45. API Versioning
About this chapter
Support multiple API versions simultaneously to evolve your API without breaking existing clients, using URL, header, or query parameter versioning strategies.
- Versioning necessity: Supporting old and new clients concurrently
- Breaking changes: When versioning is required (field removal, type changes)
- Backward compatibility: Adding fields without breaking changes
- URL versioning: Including version in the URL path (/api/v1/, /api/v2/)
- Header/query versioning: Alternative versioning schemes
- Deprecation strategies: Communicating version sunset and migration paths
Learning outcomes:
- Understand when and why to version APIs
- Implement API versioning using URL paths
- Support multiple API versions in controllers
- Handle version-specific logic and responses
- Plan deprecation timelines for old versions
- Communicate breaking changes to clients
45.1 Why Version APIs?
Without versioning:
Old client: GET /api/commands
API responds: {id, howTo, commandLine, platform}
Developer adds new field: visibility
API responds: {id, howTo, commandLine, platform, visibility}
Old client breaks:
↓
App crashes
↓
User angry
With versioning:
Old client: GET /api/v1/commands
API v1 responds: {id, howTo, commandLine, platform}
New client: GET /api/v2/commands
API v2 responds: {id, howTo, commandLine, platform, visibility}
Both work simultaneously
Old client continues working
New client gets new features
When to version:
- Breaking change: Remove a field, change type, change behavior
- Backward compatible: Add optional field, add new endpoint
- Major shifts: Complete API restructuring
Benefits:
- Support old and new clients simultaneously
- Gradual migration (not forced upgrades)
- Deprecation period (time to migrate)
- A/B testing different versions
45.2 Versioning Strategies
Option 1: URL Versioning (Recommended)
/api/v1/commands
/api/v2/commands
/api/v3/commands
Pros:
- ✓ Clear and explicit
- ✓ Easy to test (different URLs)
- ✓ Browser-friendly
- ✓ Caching friendly
Cons:
- Code duplication between versions
Option 2: Header Versioning
GET /api/commands
Header: api-version: 1
Header: api-version: 2
Header: api-version: 3
Pros:
- ✓ Single URL
- ✓ Client controls version
Cons:
- Harder to test
- Not browser-friendly
- Can confuse clients
Option 3: Query Parameter Versioning
/api/commands?apiVersion=1
/api/commands?apiVersion=2
Pros:
- Single URL
- Browser-friendly
Cons:
- Easy to forget
- Messy URLs
We’ll use URL versioning—clearest and most practical.
45.3 Installing Versioning Packages
Add NuGet packages:
dotnet add package Asp.Versioning.Mvc.ApiExplorer
dotnet add package Asp.Versioning.Mvc
This provides:
- Version routing
- Version negotiation
- API documentation per version
45.4 Setting Up URL Versioning
Configure in Program.cs:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
// Add API versioning
builder.Services.AddApiVersioning(options =>
{
// Default version if client doesn't specify
options.DefaultApiVersion = new ApiVersion(1, 0);
// Assume default version if not specified
options.AssumeDefaultVersionWhenUnspecified = true;
// Report supported versions in response headers
options.ReportApiVersions = true;
// Version reader strategies
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/...
new HeaderApiVersionReader("api-version"), // Header: api-version: 1
new QueryStringApiVersionReader("v") // ?v=1
);
})
.AddMvc();
// Add versioned Swagger
builder.Services.AddApiVersioning()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
app.MapControllers();
app.Run();
45.5 V1 Controller (Original)
Create version 1 controller:
// Controllers/V1/CommandsController.cs
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace CommandAPI.Controllers.V1
{
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class CommandsController : ControllerBase
{
private readonly ICommandRepository _repository;
private readonly IMapper _mapper;
public CommandsController(ICommandRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
[HttpGet("{id}")]
public async Task<ActionResult<CommandReadDtoV1>> GetCommandById(int id)
{
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
{
return NotFound();
}
return Ok(_mapper.Map<CommandReadDtoV1>(command));
}
[HttpGet]
public async Task<ActionResult<IEnumerable<CommandReadDtoV1>>> GetCommands()
{
var commands = await _repository.GetCommandsAsync();
return Ok(_mapper.Map<IEnumerable<CommandReadDtoV1>>(commands));
}
[HttpPost]
public async Task<ActionResult<CommandReadDtoV1>> CreateCommand(CommandMutateDtoV1 createDto)
{
var command = _mapper.Map<Command>(createDto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
return CreatedAtRoute(nameof(GetCommandById),
new { id = command.Id },
_mapper.Map<CommandReadDtoV1>(command));
}
}
}
// Models/V1/CommandReadDtoV1.cs
public class CommandReadDtoV1
{
public int Id { get; set; }
public string HowTo { get; set; }
public string CommandLine { get; set; }
public int PlatformId { get; set; }
}
// Models/V1/CommandMutateDtoV1.cs
public class CommandMutateDtoV1
{
[Required]
[MaxLength(250)]
public string HowTo { get; set; }
[Required]
public string CommandLine { get; set; }
[Required]
public int PlatformId { get; set; }
}
Endpoints:
GET /api/v1/commands (list)
GET /api/v1/commands/{id} (get one)
POST /api/v1/commands (create)
PUT /api/v1/commands/{id} (update)
DELETE /api/v1/commands/{id} (delete)
45.6 V2 Controller (With New Field)
Scenario: Add visibility field (private/public commands)
// Models/V2/CommandReadDtoV2.cs
public class CommandReadDtoV2
{
public int Id { get; set; }
public string HowTo { get; set; }
public string CommandLine { get; set; }
public int PlatformId { get; set; }
public string Visibility { get; set; } = "public"; // NEW
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // NEW
}
// Models/V2/CommandMutateDtoV2.cs
public class CommandMutateDtoV2
{
[Required]
[MaxLength(250)]
public string HowTo { get; set; }
[Required]
public string CommandLine { get; set; }
[Required]
public int PlatformId { get; set; }
[AllowedValues("private", "public")]
public string Visibility { get; set; } = "public"; // NEW
}
// Controllers/V2/CommandsController.cs
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace CommandAPI.Controllers.V2
{
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class CommandsController : ControllerBase
{
private readonly ICommandRepository _repository;
private readonly IMapper _mapper;
public CommandsController(ICommandRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
[HttpGet("{id}")]
public async Task<ActionResult<CommandReadDtoV2>> GetCommandById(int id)
{
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
{
return NotFound();
}
return Ok(_mapper.Map<CommandReadDtoV2>(command));
}
[HttpGet]
public async Task<ActionResult<IEnumerable<CommandReadDtoV2>>> GetCommands(
[FromQuery] string visibility = null)
{
var commands = await _repository.GetCommandsAsync();
// Filter by visibility (V2 feature)
if (!string.IsNullOrEmpty(visibility))
{
commands = commands
.Where(c => c.Visibility == visibility)
.ToList();
}
return Ok(_mapper.Map<IEnumerable<CommandReadDtoV2>>(commands));
}
[HttpPost]
public async Task<ActionResult<CommandReadDtoV2>> CreateCommand(CommandMutateDtoV2 createDto)
{
var command = _mapper.Map<Command>(createDto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
return CreatedAtRoute(nameof(GetCommandById),
new { id = command.Id },
_mapper.Map<CommandReadDtoV2>(command));
}
}
}
Endpoints:
GET /api/v2/commands?visibility=private (V2 filter feature)
GET /api/v2/commands/{id} (includes CreatedAt, Visibility)
POST /api/v2/commands (accepts Visibility)
45.7 Deprecation Strategy
Deprecate v1 by announcing support end:
// Controllers/V1/CommandsController.cs
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0", Deprecated = true)] // Mark as deprecated
[Obsolete("API v1 is deprecated. Use v2 instead.")]
public class CommandsController : ControllerBase
{
// ...
}
Add deprecation headers:
// Middleware/DeprecationHeaderMiddleware.cs
public class DeprecationHeaderMiddleware
{
private readonly RequestDelegate _next;
public DeprecationHeaderMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.ToString().Contains("/v1/"))
{
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Sunset"] = "Sun, 01 Jan 2026 00:00:00 GMT";
context.Response.Headers["Link"] = "<https://api.example.com/v2>; rel=\"successor-version\"";
}
await _next(context);
}
}
// Program.cs
app.UseMiddleware<DeprecationHeaderMiddleware>();
Client sees deprecation headers:
Deprecation: true
Sunset: Sun, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2>; rel="successor-version"
45.8 Testing Multiple Versions
Test both versions:
// Tests/Controllers/V1/CommandsControllerV1Tests.cs
public class CommandsControllerV1Tests : IClassFixture<CommandsApiFactory>
{
private readonly HttpClient _client;
public CommandsControllerV1Tests(CommandsApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetCommand_V1_ReturnsV1Format()
{
// Arrange
var commandId = 1;
// Act
var response = await _client.GetAsync($"/api/v1/commands/{commandId}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsAsync<CommandReadDtoV1>();
Assert.NotNull(content);
// V1 doesn't have Visibility or CreatedAt
var json = JObject.Parse(await response.Content.ReadAsStringAsync());
Assert.False(json.ContainsKey("visibility"));
Assert.False(json.ContainsKey("createdAt"));
}
}
// Tests/Controllers/V2/CommandsControllerV2Tests.cs
public class CommandsControllerV2Tests : IClassFixture<CommandsApiFactory>
{
private readonly HttpClient _client;
public CommandsControllerV2Tests(CommandsApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetCommand_V2_ReturnsV2Format()
{
// Arrange
var commandId = 1;
// Act
var response = await _client.GetAsync($"/api/v2/commands/{commandId}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsAsync<CommandReadDtoV2>();
Assert.NotNull(content);
// V2 includes Visibility and CreatedAt
Assert.NotNull(content.Visibility);
Assert.NotEqual(default, content.CreatedAt);
}
[Fact]
public async Task GetCommands_V2_FiltersVisibility()
{
// Arrange
// Act
var response = await _client.GetAsync("/api/v2/commands?visibility=private");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var commands = await response.Content.ReadAsAsync<List<CommandReadDtoV2>>();
Assert.All(commands, c => Assert.Equal("private", c.Visibility));
}
}
45.9 Version Negotiation
Let clients specify version by header:
// Client sends version in header
var request = new HttpRequestMessage(HttpMethod.Get, "/api/commands/1");
request.Headers.Add("api-version", "2.0");
var response = await client.SendAsync(request);
Or query parameter:
/api/commands/1?v=2
Or in URL:
/api/v2/commands/1
All three work with the versioning setup above.
45.10 API Documentation per Version
Generate docs with Swagger:
// Program.cs - Add Swagger
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddSwaggerGen(options =>
{
var provider = builder.Services.BuildServiceProvider()
.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = "CommandAPI",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated
? "This version is deprecated. Use a newer version."
: "Current version"
});
}
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
// Generate docs for each version
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerEndpoint(
$"/swagger/{description.GroupName}/swagger.json",
description.GroupName.ToUpperInvariant());
}
});
}
app.Run();
Swagger UI shows:
- v1.0 (Deprecated) ← Mark as deprecated
- v2.0 (Current) ← Latest and stable
Each version has separate documentation and endpoints.
45.11 Migration Checklist
When introducing a new version:
- Create new DTOs (v2-specific models)
- Create new controller (same routes, different namespace)
- Update mapping (add AutoMapper profiles for v2)
- Add tests (test both versions)
- Document changes (what’s new in v2)
- Announce deprecation (tell clients about v1 sunset)
- Provide migration guide (v1 → v2 upgrade path)
- Monitor usage (track v1 vs v2 traffic)
- Set sunset date (e.g., “v1 support ends Jan 1, 2026”)
- Remove deprecated (only after migration period)
45.12 Best Practices
1. Minimize versions:
// ❌ WRONG - New version for every small change
v1, v2, v3, v4, v5, v6...
// ✓ CORRECT - Group related changes
v1 (original)
v2 (add visibility, created date)
v3 (redesigned structure, breaking changes)
2. Keep versions long-term:
- Announce v2 in June
- Support v1 and v2 from June to December
- Announce v1 deprecation in July
- Sunset v1 on January 1
- Remove v1 endpoints after sunset
3. Document migration path:
## Migrate from v1 to v2
### Changes in v2:
- Added `visibility` field (default: "public")
- Added `createdAt` field
- Query string filter for visibility
### Example:
// V1
GET /api/v1/commands
// V2
GET /api/v2/commands?visibility=public
4. Avoid duplicate code:
// ❌ WRONG - Duplicate logic
class CommandsControllerV1 { /* logic */ }
class CommandsControllerV2 { /* logic */ }
// ✓ CORRECT - Shared service, different DTOs
class CommandService { /* logic */ }
class CommandsControllerV1 : uses CommandService, returns V1 DTOs
class CommandsControllerV2 : uses CommandService, returns V2 DTOs
45.13 What’s Next
You now have:
- ✓ Understanding why APIs need versioning
- ✓ Versioning strategies (URL, header, query)
- ✓ Setting up URL versioning in .NET
- ✓ Creating v1 and v2 controllers
- ✓ Deprecation headers and sunset dates
- ✓ Testing multiple versions
- ✓ Version negotiation
- ✓ Swagger docs per version
- ✓ Migration checklist
- ✓ Best practices
Next: Real-time with SignalR—Push notifications and broadcasting updates to connected clients.