Skip to main content
FullStackHero provides built-in API versioning using Asp.Versioning, enabling you to evolve your API while maintaining backward compatibility.

Overview

The API versioning system provides:
  • URL Segment Versioning: Version identifiers in the URL path (e.g., /api/v1/users)
  • Default Versioning: Automatic fallback to v1.0 when no version is specified
  • API Explorer Integration: Versioned OpenAPI/Swagger documentation
  • Version Reporting: Response headers indicate supported versions

Configuration

API versioning is configured in the Web building block:
Extensions.cs
public static IServiceCollection AddHeroVersioning(
    this IServiceCollection services)
{
    services
        .AddApiVersioning(options =>
        {
            options.ReportApiVersions = true;
            options.DefaultApiVersion = new ApiVersion(1, 0);
            options.AssumeDefaultVersionWhenUnspecified = true;
            options.ApiVersionReader = new UrlSegmentApiVersionReader();
        })
        .AddApiExplorer(options =>
        {
            options.GroupNameFormat = "'v'VVV";
            options.SubstituteApiVersionInUrl = true;
        })
        .EnableApiVersionBinding();
    
    return services;
}

Configuration Options

ReportApiVersions
bool
default:"true"
Include api-supported-versions and api-deprecated-versions headers in responses.
DefaultApiVersion
ApiVersion
default:"1.0"
The default API version when the client doesn’t specify one.
AssumeDefaultVersionWhenUnspecified
bool
default:"true"
Use the default version when no version is provided in the request.
ApiVersionReader
IApiVersionReader
How to read the API version from the request. Uses UrlSegmentApiVersionReader for URL-based versioning.

URL Segment Versioning

API versions are specified in the URL path:
GET /api/v1/users
GET /api/v2/users
GET /api/v3/users
This is the most explicit and cache-friendly versioning strategy.

Defining API Versions

Version Sets

Group related endpoints under a version set:
var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

var group = app.MapGroup("/api/v{version:apiVersion}/users")
    .WithApiVersionSet(versionSet);

Versioned Endpoints

Map endpoints to specific API versions:
// V1 endpoint
group.MapGet("/", GetUsersV1)
    .MapToApiVersion(new ApiVersion(1, 0))
    .WithName("GetUsers_v1")
    .WithSummary("Get users (v1)");

// V2 endpoint with enhanced response
group.MapGet("/", GetUsersV2)
    .MapToApiVersion(new ApiVersion(2, 0))
    .WithName("GetUsers_v2")
    .WithSummary("Get users (v2 - includes profile pictures)");

Versioning Strategies

Feature Module Structure

Organize versions within feature folders:
Modules/Identity/Features/
├── v1/
│   ├── Users/
│   │   ├── GetUser/
│   │   │   ├── GetUserQuery.cs
│   │   │   ├── GetUserHandler.cs
│   │   │   └── GetUserEndpoint.cs
│   └── Tokens/
│       ├── GenerateToken/
│       └── RefreshToken/
├── v2/
│   ├── Users/
│   │   ├── GetUser/
│   │   │   ├── GetUserQuery.cs       # V2 with enhanced fields
│   │   │   ├── GetUserHandler.cs
│   │   │   └── GetUserEndpoint.cs
│   └── ...
FullStackHero uses this approach: each version is a separate folder under the feature’s Features directory.

Shared Code

Extract common logic to shared services or base classes:
// Shared service used by both v1 and v2
public class UserService
{
    public async Task<User> GetUserByIdAsync(Guid id, CancellationToken ct)
    {
        // Common user retrieval logic
    }
}

// V1 handler
public class GetUserV1Handler : IQueryHandler<GetUserV1Query, UserV1Response>
{
    private readonly UserService _userService;

    public async ValueTask<UserV1Response> Handle(
        GetUserV1Query query, CancellationToken ct)
    {
        var user = await _userService.GetUserByIdAsync(query.UserId, ct);
        return new UserV1Response(user.Id, user.Name, user.Email);
    }
}

// V2 handler with extended response
public class GetUserV2Handler : IQueryHandler<GetUserV2Query, UserV2Response>
{
    private readonly UserService _userService;

    public async ValueTask<UserV2Response> Handle(
        GetUserV2Query query, CancellationToken ct)
    {
        var user = await _userService.GetUserByIdAsync(query.UserId, ct);
        return new UserV2Response(
            user.Id, 
            user.Name, 
            user.Email, 
            user.ProfilePictureUrl, // New in V2
            user.CreatedAt);         // New in V2
    }
}

Response Headers

When ReportApiVersions is enabled, responses include version information:
HTTP/1.1 200 OK
api-supported-versions: 1.0, 2.0
api-deprecated-versions: (none)
api-supported-versions
Comma-separated list of supported API versions
api-deprecated-versions
Comma-separated list of deprecated API versions

Deprecating API Versions

Mark versions as deprecated:
var versionSet = app.NewApiVersionSet()
    .HasDeprecatedApiVersion(new ApiVersion(1, 0)) // Deprecated
    .HasApiVersion(new ApiVersion(2, 0))
    .HasApiVersion(new ApiVersion(3, 0))
    .ReportApiVersions()
    .Build();
Deprecated versions still work but are marked in response headers:
api-deprecated-versions: 1.0
Communicate deprecation timelines to API consumers well in advance. Provide migration guides to newer versions.

OpenAPI/Swagger Integration

API versions are automatically reflected in OpenAPI/Swagger documentation:
  • Separate Specs: Each version gets its own OpenAPI specification
  • Version Selector: Swagger UI includes a version dropdown
  • Substitution: The {version:apiVersion} placeholder is replaced with the actual version
{
  "openapi": "3.0.1",
  "info": {
    "title": "FSH API",
    "version": "v1"
  },
  "paths": {
    "/api/v1/users": { ... },
    "/api/v1/users/{id}": { ... }
  }
}

OpenAPI

Learn more about OpenAPI configuration and documentation

Example: Versioning a Feature

Let’s version the “Get User” endpoint:
1

Create V1 Implementation

v1/GetUser/GetUserEndpoint.cs
public static RouteHandlerBuilder MapGetUserEndpoint(
    this IEndpointRouteBuilder endpoints)
{
    return endpoints.MapGet("/users/{id:guid}",
        async (Guid id, IMediator mediator, CancellationToken ct) =>
        {
            var query = new GetUserV1Query(id);
            var result = await mediator.Send(query, ct);
            return TypedResults.Ok(result);
        })
        .MapToApiVersion(new ApiVersion(1, 0))
        .WithName("GetUser_v1")
        .RequirePermission("users.view");
}
2

Add V2 with Breaking Changes

v2/GetUser/GetUserEndpoint.cs
public static RouteHandlerBuilder MapGetUserEndpoint(
    this IEndpointRouteBuilder endpoints)
{
    return endpoints.MapGet("/users/{id:guid}",
        async (Guid id, IMediator mediator, CancellationToken ct) =>
        {
            var query = new GetUserV2Query(id);
            var result = await mediator.Send(query, ct);
            return TypedResults.Ok(result);
        })
        .MapToApiVersion(new ApiVersion(2, 0))
        .WithName("GetUser_v2")
        .RequirePermission("users.view");
}
3

Define Version Set

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

var group = app.MapGroup("/api/v{version:apiVersion}")
    .WithApiVersionSet(versionSet);

group.MapGetUserEndpoint();
4

Register Endpoints

Both v1 and v2 handlers are registered in the DI container and routed based on the URL version.

Version Negotiation

Explicit Version

Client specifies the exact version:
GET /api/v2/users HTTP/1.1

Default Version

Client omits the version (uses default v1.0):
GET /api/users HTTP/1.1
This is only possible when AssumeDefaultVersionWhenUnspecified is true.

Best Practices

Follow semantic versioning principles:
  • Major version (v1, v2, v3): Breaking changes
  • Minor version (v1.1, v1.2): Backward-compatible features
  • Patch version (v1.0.1): Backward-compatible bug fixes
Only introduce breaking changes in major versions. Minor and patch versions should be backward-compatible.
Mark versions as deprecated for at least one release cycle before removing them.
Maintain a changelog that clearly documents what changed in each version.
Write integration tests for each supported API version to ensure they work independently.

Testing Versioned APIs

Test that version routing works correctly:
[Theory]
[InlineData("v1", "UserV1Response")]
[InlineData("v2", "UserV2Response")]
public async Task GetUser_ReturnsCorrectVersion(
    string version, string expectedType)
{
    // Arrange
    var client = _factory.CreateClient();
    var userId = Guid.NewGuid();

    // Act
    var response = await client.GetAsync(
        $"/api/{version}/users/{userId}");

    // Assert
    response.EnsureSuccessStatusCode();
    var content = await response.Content.ReadAsStringAsync();
    Assert.Contains(expectedType, content);
}

OpenAPI

Generate versioned OpenAPI specifications

Endpoints

Learn about endpoint mapping and routing

CQRS

Understand command and query patterns in versioned features

Testing

Write tests for versioned APIs

Build docs developers (and LLMs) love