Versioned Controllers — Maintaining Multiple API Versions

Maintaining multiple API versions in parallel requires careful code organisation to avoid duplicating business logic. The pattern used in production: separate controller classes for each major version (sharing a common service layer), version-specific DTOs for input/output shapes, and a service layer designed to satisfy multiple versions without branching. This lesson shows how to structure a codebase that supports v1 and v2 simultaneously with minimal duplication.

Versioned Controller Pattern

// ── Shared service interface — serves both versions ────────────────────────
public interface IPostService
{
    Task<PostV1Dto?>  GetByIdV1Async(int id, CancellationToken ct);
    Task<PostV2Dto?>  GetByIdV2Async(int id, CancellationToken ct);  // richer DTO
    Task<PagedResult<PostSummaryDto>> GetPageAsync(int page, int size, CancellationToken ct);
}

// ── Version 1 controller ──────────────────────────────────────────────────
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/posts")]
public class PostsV1Controller(IPostService service) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<PostV1Dto>> GetById(int id, CancellationToken ct)
    {
        var post = await service.GetByIdV1Async(id, ct);
        return post is null ? NotFound() : Ok(post);
    }
}

// ── Version 2 controller — different DTO, new fields ─────────────────────
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/posts")]
public class PostsV2Controller(IPostService service) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<PostV2Dto>> GetById(int id, CancellationToken ct)
    {
        var post = await service.GetByIdV2Async(id, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // New endpoint only in v2
    [HttpGet("{id:int}/analytics")]
    public async Task<ActionResult<PostAnalyticsDto>> GetAnalytics(int id, CancellationToken ct)
        => Ok(await service.GetAnalyticsAsync(id, ct));
}

// ── Version-specific DTOs ────────────────────────────────────────────────
public record PostV1Dto
{
    public int     Id         { get; init; }
    public string  Title      { get; init; } = string.Empty;
    public string  Body       { get; init; } = string.Empty;
    public string  AuthorName { get; init; } = string.Empty;  // string in v1
}

public record PostV2Dto
{
    public int       Id          { get; init; }
    public string    Title       { get; init; } = string.Empty;
    public string    Body        { get; init; } = string.Empty;
    public AuthorDto Author      { get; init; } = null!;  // object in v2 (breaking change)
    public int       ViewCount   { get; init; }           // new in v2
    public string[]  Tags        { get; init; } = [];     // new in v2
}
Note: The key design principle: the service layer is version-agnostic — it knows nothing about HTTP versioning. It provides query methods that return rich domain data. The versioned controllers call the appropriate service method and shape the response into the version-specific DTO. This keeps versioning concerns in the API layer (controllers and DTOs) and business logic in the service layer (shared across versions). When v3 is needed, add new controller and DTO classes without touching service or repository code.
Tip: Use the [MapToApiVersion] attribute to assign individual actions within a single controller class to specific versions — avoiding the need for separate controller files for minor version differences. A controller marked with [ApiVersion("1.0")] [ApiVersion("2.0")] can have some actions decorated with [MapToApiVersion("2.0")] (v2 only) and others available to both versions. Use separate controller files only when v1 and v2 differ significantly enough that a shared class becomes confusing.
Warning: Every version you maintain is a version you must support, test, and document. The most common versioning mistake is creating versions too eagerly. Before creating v2, ask: can this change be made in a non-breaking way? Adding fields, adding optional parameters, and adding new endpoints are all non-breaking. Only changes that remove fields, rename fields, change field types, or change behaviour require a new version. A well-designed v1 API can often absorb many rounds of evolution without needing v2.

Minimising Version Creep

Change Type Breaking? Strategy
Add optional response field No Add to existing version
Add optional request parameter No Add to existing version
Add new endpoint No Add to existing version
Rename a response field Yes New version + deprecate old
Remove a response field Yes New version + deprecate old
Change field type (string → object) Yes New version + deprecate old
Change status code semantics Yes New version + deprecate old

Common Mistakes

Mistake 1 — Duplicating business logic across version-specific controllers

❌ Wrong — PostsV1Controller and PostsV2Controller each have their own EF Core queries and service logic.

✅ Correct — shared service layer; controllers call service methods and map to version-specific DTOs.

Mistake 2 — Creating a new version for non-breaking changes

❌ Wrong — adding a new optional tags field to the response creates a v2.

✅ Correct — additive changes (new optional fields, new optional parameters) are non-breaking; add to the existing version.

🧠 Test Yourself

V1 returns authorName: "John Doe" (string). V2 changes it to author: { id: 7, name: "John Doe", avatarUrl: "..." } (object). An Angular client built for v1 calls the v2 endpoint. What happens?