Core API API Versioning

As the BookStore API evolves, breaking changes become inevitable — a field gets renamed, a response shape changes, or an endpoint is removed. Changing the API without warning breaks all existing clients. API versioning solves this by allowing multiple versions of the API to run simultaneously, so old clients continue to work while new clients use the latest version.

Why Version an API?

ScenarioWithout VersioningWith Versioning
Rename authorauthorNameAll clients break immediatelyV1 keeps author, V2 uses authorName
Remove an endpointClients get 404 with no warningV1 still serves it, V2 removes it
Add required fieldsOld POST requests fail validationV1 still accepts old format

Step 1 – Install the Versioning Package

dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer

Step 2 – Register Versioning in Program.cs

// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);   // default is v1.0
    options.AssumeDefaultVersionWhenUnspecified = true; // use default if no version given
    options.ReportApiVersions = true;                   // include version info in response headers
})
.AddMvc();

Three Ways to Send the Version

Option 1 – URL Segment (Most Common)

GET /api/v1/books
GET /api/v2/books

Option 2 – Query String

GET /api/books?api-version=1.0
GET /api/books?api-version=2.0

Option 3 – HTTP Header

GET /api/books
x-api-version: 1.0

URL segment versioning is the most readable and recommended approach for the BookStore API.

Step 3 – Create Versioned Controllers

Version 1 Controller

// Controllers/V1/BooksController.cs
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using BookStoreAPI.DTOs;
using BookStoreAPI.Services;

namespace BookStoreAPI.Controllers.V1
{
    [ApiController]
    [ApiVersion("1.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class BooksController : ControllerBase
    {
        private readonly IBookService _bookService;

        public BooksController(IBookService bookService)
        {
            _bookService = bookService;
        }

        [HttpGet]
        public async Task<IActionResult> GetAll()
        {
            var books = await _bookService.GetAllAsync();

            // V1 returns a simpler response format
            var result = books.Select(b => new
            {
                b.Id,
                b.Title,
                b.Author,
                b.Price
            });

            return Ok(result);
        }

        [HttpGet("{id:int}")]
        public async Task<IActionResult> GetById(int id)
        {
            var book = await _bookService.GetByIdAsync(id);
            if (book == null) return NotFound();
            return Ok(new { book.Id, book.Title, book.Author, book.Price });
        }
    }
}

Version 2 Controller (with more data)

// Controllers/V2/BooksController.cs
using Asp.Versioning;
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using BookStoreAPI.DTOs;
using BookStoreAPI.Models;
using BookStoreAPI.Services;
using Microsoft.AspNetCore.Authorization;

namespace BookStoreAPI.Controllers.V2
{
    [ApiController]
    [ApiVersion("2.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class BooksController : ControllerBase
    {
        private readonly IBookService _bookService;
        private readonly IMapper _mapper;

        public BooksController(IBookService bookService, IMapper mapper)
        {
            _bookService = bookService;
            _mapper = mapper;
        }

        // V2 returns full BookResponseDto with all fields
        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> GetAll([FromQuery] string? category,
                                                [FromQuery] int page = 1,
                                                [FromQuery] int pageSize = 10)
        {
            var books = await _bookService.GetAllAsync();

            if (!string.IsNullOrEmpty(category))
                books = books.Where(b => b.Category == category).ToList();

            var paged = books.Skip((page - 1) * pageSize).Take(pageSize).ToList();
            var result = _mapper.Map<List<BookResponseDto>>(paged);

            return Ok(new
            {
                page,
                pageSize,
                totalCount = books.Count,
                data = result
            });
        }

        [HttpGet("{id:int}")]
        [AllowAnonymous]
        public async Task<IActionResult> GetById(int id)
        {
            var book = await _bookService.GetByIdAsync(id);
            if (book == null) return NotFound();
            return Ok(_mapper.Map<BookResponseDto>(book));
        }

        [HttpPost]
        [Authorize(Roles = "Admin")]
        public async Task<IActionResult> Create([FromBody] BookCreateDto dto)
        {
            var book = _mapper.Map<Book>(dto);
            var created = await _bookService.CreateAsync(book);
            var result = _mapper.Map<BookResponseDto>(created);
            return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
        }

        [HttpPut("{id:int}")]
        [Authorize(Roles = "Admin")]
        public async Task<IActionResult> Update(int id, [FromBody] BookUpdateDto dto)
        {
            var book = _mapper.Map<Book>(dto);
            var success = await _bookService.UpdateAsync(id, book);
            if (!success) return NotFound();
            return NoContent();
        }

        [HttpDelete("{id:int}")]
        [Authorize(Roles = "Admin")]
        public async Task<IActionResult> Delete(int id)
        {
            var success = await _bookService.DeleteAsync(id);
            if (!success) return NotFound();
            return NoContent();
        }
    }
}

V1 vs V2 Response Comparison

GET /api/v1/books/1
Response:
{
  "id": 1,
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "price": 29.99
}

────────────────────────────────────────────
GET /api/v2/books/1
Response:
{
  "id": 1,
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "price": 29.99,
  "category": "Technology",
  "isAvailable": true,
  "createdDate": "2024-01-01T00:00:00"
}

Deprecating a Version

When a version is old enough to be removed, it can be marked as deprecated. Clients still receive responses, but the response includes a header warning them:

[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/[controller]")]
public class BooksController : ControllerBase { ... }

The response will include:

api-deprecated-versions: 1.0
api-supported-versions: 2.0

Updated Project Structure

Controllers/
├── V1/
│   └── BooksController.cs    ← GET only, simple response shape
├── V2/
│   └── BooksController.cs    ← Full CRUD, paging, full response
│   └── AuthController.cs

Key Points

  • API versioning allows multiple versions of the same API to run simultaneously without breaking existing clients.
  • URL segment versioning (/api/v1/books) is the clearest and most recommended approach.
  • V1 and V2 are separate controller classes, typically in separate folders (Controllers/V1, Controllers/V2).
  • Deprecated versions continue to function but inform clients through response headers that they should migrate.
  • AssumeDefaultVersionWhenUnspecified = true ensures backward compatibility for clients that do not send a version.

Leave a Comment