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
}
[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.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.