API Versioning: Changing the API Without Breaking Apps – Web API | Lesson 93
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.Mvcpackage. - 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?
| Change | Breaking? | Why |
|---|---|---|
| Add a new field to a response | no | older apps ignore fields they do not know |
| Add a new endpoint | no | nobody calls it yet |
| Add an optional request field | no | old requests still work without it |
| Remove or rename a field | yes | old apps read a field that is gone |
| Change a field's type or meaning | yes | old apps misread it |
| Make an optional request field required | yes | old requests are refused |
| Change a status code, such as 200 to 201 | yes | old 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:
{ "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
| Style | Example | Notes |
|---|---|---|
| URL segment | /api/v2/students/ADM-2017-0003 | visible, easy to test in a browser, works with caches; the SMS uses it |
| Header | X-Api-Version: 2 | clean URLs, but invisible in links and logs |
| Query string | /api/students/ADM-2017-0003?api-version=2.0 | easy 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
// 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:
[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
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:
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
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-versionsand 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:
- Announce. Mark the old version with
[ApiVersion("1.0", Deprecated = true)]. Responses then list it in anapi-deprecated-versionsheader, and the documentation gives a date. - Watch. Log which version each request uses, and see who still calls version 1.
- 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
- Changing a field in place. Installed apps break overnight. Fix: add fields, or add a version.
- A version for every small change. Ten versions to maintain. Fix: prefer additive changes; version only for breaking ones.
if (version == 2)inside one action. The versions tangle together. Fix: a separate controller or action per version.- 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.
- Never retiring anything. Old versions pile up. Fix: deprecate with a date, watch usage, then remove.
In real 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.Versioningreads the version from/api/v{version}/...and defaults to 1.0.- Version 1 kept
fullName; version 2 returnsfirstNameandlastName. api-supported-versions: 1.0, 2.0tells 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.
- Version 1,
GET /api/books/{isbn}(and/api/v1/...), returns the ISBN and title. - Version 2,
GET /api/v2/books/{isbn}, also returns the author and the number of copies. - Both must report
api-supported-versions: 1.0, 2.0.
A correct result looks like:
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 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
Which change breaks existing apps?
Show answer
Answer: B. Renaming a field in a response Old apps read a field that no longer exists.
What does AssumeDefaultVersionWhenUnspecified do in the SMS?
Show answer
Answer: B. Gives requests without a version 1.0, so existing apps keep working /api/students still answers as version 1.
Why did version 1 first report only 1.0, and version 2 only 2.0?
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.
Why does version 2 have its own controller?
Show answer
Answer: B. Version 1 stays untouched, with no version checks inside actions No change for version 2 can slip into version 1.
What should happen before an old version is removed?
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.