#
Versioning your Web API
#
Overview
This plan adds API versioning using URL segments (e.g., /api/v1/albums) to cleanly separate versions, work seamlessly with HTTP response caching, and prepare for multi-version Swagger. You’ll:
- Install API versioning packages
- Register API versioning in
Program.cs - Annotate controllers and adjust routes
- Prepare for deprecation/sunset
- Validate caching behavior
- Add test coverage
Why URL segments? They naturally partition caches (path differs per version), are easy to reason about, and Swagger grouping is straightforward. Your domain cache (VersionedMemoryCache) remains unchanged, because API versioning is a surface concern.
Where you will code:
Chinook.API/Program.cs(service registration)Chinook.API/Controllers/*Controller.cs(attributes/routes)- Optional: custom middleware/filter for deprecation headers
- Tests: new test project or existing test suite
#
Step 1 — Add NuGet dependencies
Why: Brings in the MVC API versioning services and the API explorer needed later for Swagger to emit one document per version.
- API versioning core:
Microsoft.AspNetCore.Mvc.Versioning
- API explorer integration (for Swagger grouping later):
Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer
Commands:
dotnet add Chinook.API package Microsoft.AspNetCore.Mvc.Versioning
dotnet add Chinook.API package Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer
#
Step 2 — Register API versioning in Program.cs
Why: Configures how versions are read (URL segment), defaulting behavior, and exposes helpful headers.
Edit Chinook.API/Program.cs and add the following service registrations right after controllers are added (or before building the app):
using Microsoft.AspNetCore.Mvc; // for ApiVersion
using Microsoft.AspNetCore.Mvc.Versioning;
using Microsoft.AspNetCore.Mvc.ApiExplorer; // later for Swagger
var builder = WebApplication.CreateBuilder(args);
// ... existing service registrations
builder.Services.AddControllers();
// API Versioning
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = false; // force explicit versioning
options.ReportApiVersions = true; // adds api-supported-versions, api-deprecated-versions headers
options.ApiVersionReader = new UrlSegmentApiVersionReader(); // version in the URL path
});
// Versioned API Explorer (used by Swagger later)
builder.Services.AddVersionedApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV"; // e.g., v1, v1.1
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
// ... existing middleware (UseCors, UseResponseCaching, etc.)
Notes:
- Keep
app.UseResponseCaching()as-is. URL segment versioning inherently splits cached entries by path. - If you prefer a soft rollout, temporarily set
AssumeDefaultVersionWhenUnspecified = trueand remove it after client migration.
#
Step 3 — Annotate controllers and update route templates
Why: Declares supported versions and makes the version part of routes like /api/v1/album.
For each API controller under Chinook.API/Controllers:
- Add an API version attribute at the class level
- Change the route to include a version URL segment
Example for AlbumController:
using Microsoft.AspNetCore.Mvc;
namespace Chinook.API.Controllers;
[ApiVersion("1.0")] // declare this controller belongs to v1
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[EnableCors("CorsPolicy")]
public class AlbumController : ControllerBase
{
// ... existing code
}
Repeat for each controller (Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, Track, etc.).
Version-neutral endpoints (e.g., health/home):
[ApiVersionNeutral]
[Route("/health")] // or whatever your endpoint is
public class HealthController : ControllerBase { /* ... */ }
Notes:
- With URL segments, you do NOT need to add
Varydirectives for response caching—the path differs by version. - When you introduce v2, you can either:
- Create separate controller classes in a
V2folder/namespace with[ApiVersion("2.0")], same route template; or Keep a single controller and map specific actions:
[ApiVersion("1.0")] [ApiVersion("2.0")] [Route("api/v{version:apiVersion}/[controller]")] public class AlbumController : ControllerBase { [HttpGet] [MapToApiVersion("1.0")] // v1 behavior public IActionResult GetV1() => Ok(/* ... */); [HttpGet] [MapToApiVersion("2.0")] // v2 behavior public IActionResult GetV2() => Ok(/* ... */); }
- Create separate controller classes in a
#
Step 4 — Prepare for deprecation and sunset (optional but recommended)
Why: Communicates lifecycle changes to clients proactively.
- Mark deprecated versions at the controller level:
[ApiVersion("1.0", Deprecated = true)]
- Add a custom middleware or action filter to inject
Sunsetand documentation links for deprecated versions:
public class SunsetHeaderMiddleware
{
private readonly RequestDelegate _next;
private readonly DateTimeOffset _sunsetDate;
public SunsetHeaderMiddleware(RequestDelegate next, DateTimeOffset sunsetDate)
{
_next = next;
_sunsetDate = sunsetDate;
}
public async Task Invoke(HttpContext context)
{
await _next(context);
// conditionally add header for deprecated versions only, if desired
context.Response.Headers.TryAdd("Sunset", _sunsetDate.ToString("R"));
context.Response.Headers.TryAdd("Link", "</docs/migration>; rel=\"deprecation\"");
}
}
// In Program.cs
app.UseMiddleware<SunsetHeaderMiddleware>(new DateTimeOffset(2026, 01, 01, 0, 0, 0, TimeSpan.Zero));
#
Step 5 — Validate and align with response caching
Why: Ensure caches don’t mix content across versions.
- Keep
app.UseResponseCaching(). - If you add
[ResponseCache]attributes, you can keep them as-is with URL segment versioning. - If at any point you switch to header or query-string versioning, add appropriate varies:
Header-based (e.g.,
x-api-version):[ResponseCache(VaryByHeader = "x-api-version")]Query-based (e.g.,
api-version):[ResponseCache(VaryByQueryKeys = new[] { "api-version" })]
Your domain-level VersionedMemoryCache does not require changes—API versioning doesn’t affect those keys.
#
Step 6 — Rollout plan and compatibility window
Why: Avoid breaking existing clients overnight.
- Option A (strict): Require
/api/v1/...immediately (AssumeDefaultVersionWhenUnspecified = false). - Option B (graceful): Temporarily allow unversioned routes:
- Set
AssumeDefaultVersionWhenUnspecified = trueso old/api/[controller]resolves to v1. - Communicate migration deadline and later revert to
false.
- Set
- Option C (dual endpoints): Expose both old and new routes by keeping a compatibility controller or duplicate
[Route]attributes for a short window, then remove the legacy route.
#
Step 7 — Testing the versioning (unit/integration)
Why: Proves routing, version selection, headers, and caching behavior are correct.
- Test harness:
Microsoft.AspNetCore.Mvc.TestingwithWebApplicationFactory<Program>to run the API in-memory.
Example tests (xUnit-style):
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class VersioningTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public VersioningTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Get_V1_Album_Should_Return_200()
{
var res = await _client.GetAsync("/api/v1/album");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.True(res.Headers.Contains("api-supported-versions"));
}
[Fact]
public async Task Get_V2_Album_Should_Return_404_When_Not_Implemented()
{
var res = await _client.GetAsync("/api/v2/album");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
}
Caching differentiation test (conceptual):
[Fact]
public async Task ResponseCache_Should_Not_Mix_Versions()
{
// First request to v1
var res1 = await _client.GetAsync("/api/v1/album");
res1.EnsureSuccessStatusCode();
// Then to v2 (assuming different behavior or not implemented)
var res2 = await _client.GetAsync("/api/v2/album");
// Expect different status/content; at minimum, not the same response object
Assert.NotEqual(res1.StatusCode, res2.StatusCode);
}
Domain cache independence (with repository mocks) — pseudocode outline:
// Arrange a test server with mocked repository and in-memory IMemoryCache
// Call /api/v1/album to populate domain cache via supervisor
// Call /api/v2/album (same underlying domain call) and assert repository call count has not increased
// demonstrating domain cache is shared across API versions
Swagger API Explorer (when added):
// Resolve IApiVersionDescriptionProvider, assert it contains v1, and v1 is marked deprecated when you set Deprecated = true
#
Step 8 — Prepare for Swagger (next iteration)
Why: You’ll want separate Swagger docs per version.
When you add Swashbuckle:
using Microsoft.AspNetCore.Mvc.ApiExplorer;
builder.Services.AddSwaggerGen(options =>
{
using var provider = builder.Services.BuildServiceProvider()
.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = $"Chinook API {description.ApiVersion}",
Version = description.GroupName,
Description = description.IsDeprecated ? "This API version has been deprecated." : null
});
}
});
// app.UseSwagger(); app.UseSwaggerUI(c => { ... add endpoints per description ... });
#
Acceptance checklist
- All controllers routed under
/api/v1/...and annotated with[ApiVersion("1.0")]. AddApiVersioningandAddVersionedApiExplorerregistered.- Response caching remains valid (no mixing across versions).
- Optional deprecation/sunset headers in place when needed.
- Tests passing for routing, headers, cache behavior, and version explorer.
- Ready to add Swagger with one doc per version.
If you’d like, I can turn this plan into a file-by-file change checklist for your repo and prepare a starting PR once you confirm the approach.