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?
| Scenario | Without Versioning | With Versioning |
|---|---|---|
Rename author → authorName | All clients break immediately | V1 keeps author, V2 uses authorName |
| Remove an endpoint | Clients get 404 with no warning | V1 still serves it, V2 removes it |
| Add required fields | Old POST requests fail validation | V1 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 = trueensures backward compatibility for clients that do not send a version.
