Skip to main content

API Versioning: Changing the API Without Breaking Apps – Web API | Lesson 93

Published / updated

Introduction​

The SMS API returns a student's name as one field, fullName. The parent app now wants the first and last names separately, to sort lists and to write "Dear Mr Singh" properly.

Changing fullName into two fields would break every app already installed on parents' phones. Phones update apps slowly, and some never do. This lesson adds version 2 of the endpoint, while version 1 keeps answering exactly as before.

By the end of this lesson, you will be able to:

  • Tell a breaking change from a safe one.
  • Compare URL, header, and query-string versioning.
  • Add versions with the Asp.Versioning.Mvc package.
  • Serve version 1 and version 2 of one endpoint side by side.
  • Report supported versions, and plan how old versions are retired.

Why this matters​

A web page updates the moment you deploy it. A mobile app does not: some parents will run last term's app for months. An API used by apps is a promise, and every field in it is part of that promise.

Breaking that promise has a real cost in a school. Report cards show blank names, a fee screen shows nothing, and the support phone rings all morning while a fixed app waits for the app store to approve it.

Versioning lets the API grow without breaking that promise. New apps use the new version; old apps keep the old one until they move, at their own pace.

The concept​

Think of the school's printed admission form. When the office needs separate boxes for first and last names, it prints version 2 of the form. Forms already filled in on version 1 are still accepted, and the office stops handing out version 1 only after a notice period.

Example: breaking or not?​

ChangeBreaking?Why
Add a new field to a responsenoolder apps ignore fields they do not know
Add a new endpointnonobody calls it yet
Add an optional request fieldnoold requests still work without it
Remove or rename a fieldyesold apps read a field that is gone
Change a field's type or meaningyesold apps misread it
Make an optional request field requiredyesold requests are refused
Change a status code, such as 200 to 201yesold apps may check it

Splitting fullName into firstName and lastName removes a field, so it is breaking. Adding the two new fields alongside fullName would not be, and is often the better first choice. This lesson takes the breaking road on purpose, to show versioning.

Example: the additive alternative​

Before reaching for a new version, ask whether the change can be additive. For the names, version 1 could simply gain two fields:

Version 1, with fields added (not used in the SMS)
{ "fullName": "Kabir Singh", "firstName": "Kabir", "lastName": "Singh", ... }

Old apps keep reading fullName; new apps read the new fields. Nothing breaks, and there is only one version to maintain.

Most API changes in a real school system can be done this way. Versions are for the changes that cannot: removing a field, changing its meaning, or reshaping a whole response.

Example: where the version goes​

StyleExampleNotes
URL segment/api/v2/students/ADM-2017-0003visible, easy to test in a browser, works with caches; the SMS uses it
HeaderX-Api-Version: 2clean URLs, but invisible in links and logs
Query string/api/students/ADM-2017-0003?api-version=2.0easy to add, but mixes the version with filters

All three work with Asp.Versioning. Pick one and use it everywhere, so clients never have to guess.

Example: a default version​

Every SMS endpoint existed before versions did. Their addresses, such as /api/students/ADM-2017-0003, have no version in them.

AssumeDefaultVersionWhenUnspecified says: a request without a version gets 1.0. So nothing that worked yesterday stops working today.

Apply it to the SMS​

Step 1: turn versioning on​

Program.cs (the Lesson 93 lines)
// Lesson 93: API versions. A request without a version gets 1.0, so every existing app keeps working;
// /api/v2/... asks for 2.0. Responses list the versions an endpoint supports.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvc();

The option ReportApiVersions adds an api-supported-versions header to responses. A client can then find out what exists without reading documentation. The reader UrlSegmentApiVersionReader takes the version from {version:apiVersion} in a route.

Step 2: version 1 gets a name​

StudentsController changes only in its attributes:

StudentsController.cs (the Lesson 93 lines)
[ApiController]
[ApiVersion("1.0")] // Lesson 93: this controller is version 1
[Route("api/students")] // no version in the address: 1.0, as before
[Route("api/v{version:apiVersion}/students")] // /api/v1/students: the same actions, with the version named

Two routes, one controller: old apps keep calling /api/students/..., and new code can say /api/v1/... when it wants to be explicit.

Step 3: version 2 in its own controller​

src/SchoolApp.Api/Controllers/StudentsV2Controller.cs
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Api.Models;
using SchoolApp.Api.Security;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Interfaces;

namespace SchoolApp.Api.Controllers;

// Lesson 93: version 2 of "get a student". A new controller for the new version: version 1 in StudentsController
// stays exactly as it was, so every app that already uses it keeps working.
[ApiController]
[ApiVersion("2.0")]
[ControllerName("Students")] // the same name as version 1, so both report "api-supported-versions: 1.0, 2.0"
[Route("api/v{version:apiVersion}/students")]
[Produces("application/json")]
[ProducesErrorResponseType(typeof(ApiResponse))]
public class StudentsV2Controller : ControllerBase
{
private readonly IStudentRepository _students;

public StudentsV2Controller(IStudentRepository students)
{
_students = students;
}

/// <summary>Gets one student by admission number, with first and last names separate (version 2).</summary>
/// <response code="200">The student.</response>
/// <response code="404">No student has this admission number.</response>
[HttpGet("{admissionNumber}")]
[Authorize(Policy = Policies.StudentRecord)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<StudentV2Dto>>> GetByAdmissionNumber(string admissionNumber,
CancellationToken cancellationToken)
{
Student? student = await _students.GetByAdmissionNumberAsync(admissionNumber, cancellationToken);
if (student is null)
{
return NotFound(ApiResponse.Fail($"No student has admission number {admissionNumber}.", 50030));
}
StudentV2Dto dto = new StudentV2Dto(student.Id, student.AdmissionNumber, student.FirstName, student.LastName,
student.DateOfBirth, student.Gender.ToString(), student.Status.ToString());
return ApiResponse.Ok(dto, "Student found.");
}
}

A separate class keeps version 1 untouched: there is no if (version == 2) anywhere, and no chance that a change for version 2 slips into version 1.

The attribute [ControllerName("Students")] matters. Our first run showed version 1 answering api-supported-versions: 1.0 and version 2 answering 2.0.

The package groups versions by controller name, and the two classes had different names. With the same name, both answer 1.0, 2.0, and a client asking either version learns that the other exists.

Version 2 also keeps the StudentRecord policy (Lesson 87). A new version is a new door into the same data, so it needs the same locks.

Step 4: both versions, side by side​

Signed in as the principal:

Output
GET /api/students/ADM-2017-0003 200 api-supported-versions: 1.0, 2.0
{"status":"success","message":"Student found.","data":{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh","dateOfBirth":"2011-01-30","gender":"Male","status":"Active"}}

GET /api/v1/students/ADM-2017-0003 200 api-supported-versions: 1.0, 2.0
(the same answer)

GET /api/v2/students/ADM-2017-0003 200 api-supported-versions: 1.0, 2.0
{"status":"success","message":"Student found.","data":{"id":3,"admissionNumber":"ADM-2017-0003","firstName":"Kabir","lastName":"Singh","dateOfBirth":"2011-01-30","gender":"Male","status":"Active"}}

GET /api/v3/students/ADM-2017-0003 404
{"status":"error","message":"Nothing was found at GET /api/v3/students/ADM-2017-0003.","traceId":"00-3a09…"}

GET /api/v2/students?pageSize=1 404 (version 2 has only "get one student")

A version that does not exist matches no route, so the status code page from Lesson 80 answered, in the usual shape. Every other controller, such as subjects, also reports api-supported-versions: 1.0: without attributes, it belongs to the default version.

Nothing in the other fourteen controllers changed. They became "version 1" simply by existing, which is exactly what a default version is for. When one of them needs a breaking change, it gets its own version 2 in the same way as students.

Step 5: tests​

Show tests/SchoolApp.Tests/Api/VersioningTests.cs
tests/SchoolApp.Tests/Api/VersioningTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using SchoolApp.Api.Security;

namespace SchoolApp.Tests.Api;

// Lesson 93: version 1 and version 2 of "get a student", side by side.
[Collection("Api")]
public class VersioningTests
{
private readonly ApiFactory _factory;

public VersioningTests(ApiFactory factory)
{
_factory = factory;
}

private static async Task<JsonElement> DataAsync(HttpResponseMessage response) =>
(await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");

[Theory]
[InlineData("/api/students/ADM-2017-0003")] // no version: 1.0, as before Lesson 93
[InlineData("/api/v1/students/ADM-2017-0003")]
public async Task Version1_KeepsFullName(string url)
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);

HttpResponseMessage response = await client.GetAsync(url);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Kabir Singh", (await DataAsync(response)).GetProperty("fullName").GetString());
Assert.Equal("1.0, 2.0", string.Join(", ", response.Headers.GetValues("api-supported-versions")));
}

[Fact]
public async Task Version2_HasSeparateNames()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);

JsonElement data = await DataAsync(await client.GetAsync("/api/v2/students/ADM-2017-0003"));

Assert.Equal(("Kabir", "Singh"), (data.GetProperty("firstName").GetString(), data.GetProperty("lastName").GetString()));
Assert.False(data.TryGetProperty("fullName", out _));
}

[Fact]
public async Task UnknownVersion_Returns404()
{
HttpClient client = await _factory.CreateSignedInClientAsync(Roles.Principal);

HttpResponseMessage response = await client.GetAsync("/api/v3/students/ADM-2017-0003");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

// The record rule from Lesson 87 applies to version 2 as well: Neha may see Aarav, not Kabir.
[Theory]
[InlineData("ADM-2017-0001", HttpStatusCode.OK)]
[InlineData("ADM-2017-0003", HttpStatusCode.Forbidden)]
public async Task Version2_KeepsTheParentRule(string admissionNumber, HttpStatusCode expected)
{
HttpClient parent = await _factory.CreateSignedInClientAsync(Roles.Parent);

HttpResponseMessage response = await parent.GetAsync($"/api/v2/students/{admissionNumber}");

Assert.Equal(expected, response.StatusCode);
}
}

The tests check both shapes, the header, the unknown version, and the parent rule on version 2. The suite has 147 tests, all passing.

Example: versions in the OpenAPI document​

The OpenAPI document from Lesson 71 still describes every endpoint in one list, with both student routes in it. For a public API, teams usually publish one document per version, so each app sees only what its version offers. The companion package Asp.Versioning.Mvc.ApiExplorer groups endpoints by version for exactly that.

The SMS keeps one document for now: it has two versions of one endpoint, and its clients are the school's own apps. When versions grow, splitting the documentation is the next step.

Example: what a client app should do​

Versioning is a contract with two sides. A well-behaved app:

  • names the version it was built for, such as /api/v2/..., instead of relying on the default;
  • reads api-deprecated-versions and shows the team a warning in its own logs;
  • treats a 404 from a retired version as "please update the app", not as "student not found".

The parent app in this course will call version 2 from now on, and the office screens can move when they are next updated.

Example: retiring an old version​

Versions should not live forever: every version is code to test and maintain. A common plan:

  1. Announce. Mark the old version with [ApiVersion("1.0", Deprecated = true)]. Responses then list it in an api-deprecated-versions header, and the documentation gives a date.
  2. Watch. Log which version each request uses, and see who still calls version 1.
  3. Retire. After the date, remove version 1 and answer its routes with a clear error.

For a school, a sensible notice period is one full academic year. Every family will have updated their app by the next year's admissions, and the office has time to tell anyone who has not.

Common mistakes​

  1. Changing a field in place. Installed apps break overnight. Fix: add fields, or add a version.
  2. A version for every small change. Ten versions to maintain. Fix: prefer additive changes; version only for breaking ones.
  3. if (version == 2) inside one action. The versions tangle together. Fix: a separate controller or action per version.
  4. Forgetting security on the new version. Version 2 opens a door around the rules. Fix: the same policies as version 1, and tests for them.
  5. Never retiring anything. Old versions pile up. Fix: deprecate with a date, watch usage, then remove.

In real projects​

From 16 years of .NET projects

A school app team renamed studentName to name in the API, because the new app screen used name. The web portal updated at once and looked fine.

The mobile app on parents' phones still read studentName. Every child's name showed as blank, and the school's phone did not stop ringing until a new app release was approved two days later.

We put the old field back and added the change as version 2. The team also set a rule: no field is removed or renamed without a new version and a deprecation date.

Best practices​

  • Prefer additive changes; they need no new version.
  • Use one versioning style everywhere; the SMS uses the URL segment.
  • Default unversioned requests to 1.0, so existing apps keep working.
  • Keep each version in its own controller, with the same security rules.
  • Deprecate with a date, watch usage, then retire.

Recap​

  • Removing or renaming a field is a breaking change; adding one is not.
  • Asp.Versioning reads the version from /api/v{version}/... and defaults to 1.0.
  • Version 1 kept fullName; version 2 returns firstName and lastName.
  • api-supported-versions: 1.0, 2.0 tells clients what exists, once both controllers share a name.
  • A new version keeps the same security rules: 147 tests pass.

Homework​

Library module task: Version a single-file library API.

  1. Version 1, GET /api/books/{isbn} (and /api/v1/...), returns the ISBN and title.
  2. Version 2, GET /api/v2/books/{isbn}, also returns the author and the number of copies.
  3. Both must report api-supported-versions: 1.0, 2.0.

A correct result looks like:

Output
GET /api/books/9780143039655 200 {"isbn":"9780143039655","title":"Malgudi Days"} api-supported-versions: 1.0, 2.0
GET /api/v1/books/9780143039655 200 {"isbn":"9780143039655","title":"Malgudi Days"} api-supported-versions: 1.0, 2.0
GET /api/v2/books/9780143039655 200 {"isbn":"9780143039655","title":"Malgudi Days","author":"R. K. Narayan","copies":1} api-supported-versions: 1.0, 2.0
GET /api/v3/books/9780143039655 404
Show the solution
library-assignment/093-library-versions.cs
// Library homework, Lesson 93: two versions of "get a book". Version 1 returns the title only; version 2 adds the
// author and the number of copies. Requests without a version get 1.0, so old apps keep working.
// Run with: dotnet run library-assignment/093-library-versions.cs
#:sdk Microsoft.NET.Sdk.Web
#:package Asp.Versioning.Mvc@10.2.1
#:property PublishAot=false
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvc();
WebApplication app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5093");

public record Book(string Isbn, string Title, string Author, int Copies);
public record BookV1Dto(string Isbn, string Title);

public static class Catalogue
{
public static readonly List<Book> Books =
[
new Book("9780143039655", "Malgudi Days", "R. K. Narayan", 1),
new Book("9788173711466", "Wings of Fire", "A. P. J. Abdul Kalam", 2)
];
}

[ApiController]
[ApiVersion("1.0")]
[Route("api/books")]
[Route("api/v{version:apiVersion}/books")]
public class BooksController : ControllerBase
{
[HttpGet("{isbn}")]
public IActionResult Get(string isbn) =>
Catalogue.Books.SingleOrDefault(b => b.Isbn == isbn) is Book book ? Ok(new BookV1Dto(book.Isbn, book.Title)) : NotFound();
}

[ApiController]
[ApiVersion("2.0")]
[ControllerName("Books")]
[Route("api/v{version:apiVersion}/books")]
public class BooksV2Controller : ControllerBase
{
[HttpGet("{isbn}")]
public IActionResult Get(string isbn) =>
Catalogue.Books.SingleOrDefault(b => b.Isbn == isbn) is Book book ? Ok(book) : NotFound();
}

Adding author and copies to version 1 would not have broken anything. Version 2 here is for practice; in a real library, an additive change is the simpler choice.

Quiz​

  1. Which change breaks existing apps?

    1. Adding a field to a response
    2. Renaming a field in a response
    3. Adding a new endpoint
    4. Adding an optional request field
    Show answer

    Answer: B. Renaming a field in a response Old apps read a field that no longer exists.

  2. What does AssumeDefaultVersionWhenUnspecified do in the SMS?

    1. Picks the newest version
    2. Gives requests without a version 1.0, so existing apps keep working
    3. Refuses requests without a version
    4. Removes old versions
    Show answer

    Answer: B. Gives requests without a version 1.0, so existing apps keep working /api/students still answers as version 1.

  3. Why did version 1 first report only 1.0, and version 2 only 2.0?

    1. A bug in .NET
    2. The package groups versions by controller name, and the two classes had different names
    3. Headers are cached
    4. Version 2 was not registered
    Show answer

    Answer: B. The package groups versions by controller name, and the two classes had different names [ControllerName('Students')] on version 2 fixed it.

  4. Why does version 2 have its own controller?

    1. Controllers cannot have two routes
    2. Version 1 stays untouched, with no version checks inside actions
    3. It is faster
    4. OpenAPI requires it
    Show answer

    Answer: B. Version 1 stays untouched, with no version checks inside actions No change for version 2 can slip into version 1.

  5. What should happen before an old version is removed?

    1. Nothing
    2. Mark it deprecated with a date, and watch who still uses it
    3. Remove it on a Friday
    4. Rename it
    Show answer

    Answer: B. Mark it deprecated with a date, and watch who still uses it For a school, a full academic year is a sensible notice.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F What is a breaking change in an API? Give examples. concept

A change that makes existing clients fail or misread data: removing or renaming a response field, changing a field's type or meaning, making an optional request field required, or changing a status code. Adding a field, an endpoint, or an optional request field is not breaking, because old clients ignore it.

See the example · Permalink

F What are the common ways to put a version in an API request? concept

In the URL path (/api/v2/students/...), in a header (X-Api-Version: 2), or in the query string (?api-version=2.0). URL versions are visible and easy to test and cache; headers keep URLs clean but are invisible in links and logs. The SMS uses the URL segment, and the key is to use one style everywhere.

See the example · Permalink

F How do existing apps keep working when you add versioning to an API that had none? concept

Make unversioned requests mean version 1. In Asp.Versioning, set DefaultApiVersion to 1.0 and AssumeDefaultVersionWhenUnspecified to true. In the SMS, /api/students/... still answers exactly as before, and /api/v1/... is the same endpoint with the version named.

See the example · Permalink

Mid (3-5 yrs)​

M How do you serve two versions of the same endpoint in ASP.NET Core controllers? coding

Add Asp.Versioning.Mvc, mark the existing controller [ApiVersion("1.0")], and add a new controller marked [ApiVersion("2.0")] with a route such as api/v{version:apiVersion}/students. Give both the same [ControllerName] so the package groups them and reports both versions.

See the example · Permalink

M Why might api-supported-versions show only one version on each of two versioned controllers? scenario

Asp.Versioning groups versions by controller name. A StudentsController (1.0) and a StudentsV2Controller (2.0) look like two different APIs, so each reports only its own version. [ControllerName("Students")] on the version 2 class makes both report 1.0, 2.0.

See the example · Permalink

M When should you add a new API version instead of changing the existing one? design

Only for breaking changes. If the change can be additive, such as adding firstName and lastName next to fullName, do that: old clients keep working and there is one version to maintain. Add a version when a field must be removed, renamed, or change meaning, or a response must be reshaped.

See the example · Permalink

Senior (6-9 yrs)​

S A field was renamed in the API and every installed mobile app now shows blank names. What do you do? scenario

Restore the old field at once (or serve the old shape again) so installed apps work. Move the rename into a new version that new app releases use. Add a rule and a review step: no field is removed or renamed without a new version and a deprecation date, and add tests that pin the version 1 response shape.

How to answer: Put users first (restore), then do the change properly (versioning), then prevent it (rules and contract tests).

See the example · Permalink

S How do you retire an old API version safely? design

Mark it deprecated (so responses list it in api-deprecated-versions) and publish a date; log which version each request uses and contact the clients still on it; after the date, remove it and answer its routes with a clear error. For a school, one full academic year is a sensible notice period.

How to answer: Cover announcing, measuring usage, communicating, and the final removal, with a realistic timeline.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Your platform has many APIs used by internal and partner apps. How would you set a versioning policy? design

One style for all APIs, semantic rules for what counts as breaking, and a preference for additive changes. Breaking changes get a new major version with a published support period (for example, at least 12 months), deprecation headers, per-version documentation, and usage dashboards per client. Contract tests guard each supported version, and an API gateway can route versions to different services if needed.

How to answer: Cover the style, the definition of breaking, support periods, communication, documentation, enforcement with tests, and how clients are tracked.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-93-api-versioning (code repository coming soon)
  • Files: src/SchoolApp.Api/Controllers/StudentsV2Controller.cs, src/SchoolApp.Api/Controllers/StudentsController.cs, src/SchoolApp.Api/Program.cs, tests/SchoolApp.Tests/Api/VersioningTests.cs, library-assignment/093-library-versions.cs
  • Video: coming soon

What's next​

The API can change safely. Next, work that should not make anyone wait: background jobs, such as sending fee reminders after the office closes. Lesson 94: Background jobs

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.