45. API Versioning

Versioning APIs: supporting multiple versions, deprecation strategies, and client migration

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:

  1. Create new DTOs (v2-specific models)
  2. Create new controller (same routes, different namespace)
  3. Update mapping (add AutoMapper profiles for v2)
  4. Add tests (test both versions)
  5. Document changes (what’s new in v2)
  6. Announce deprecation (tell clients about v1 sunset)
  7. Provide migration guide (v1 → v2 upgrade path)
  8. Monitor usage (track v1 vs v2 traffic)
  9. Set sunset date (e.g., “v1 support ends Jan 1, 2026”)
  10. 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.