Skip to main content
ANVISoftware Solutions
Lesson 14 of 18Intermediate18 min

Versioning

By the end of this lesson

Introduce breaking changes without breaking existing consumers.

The moment something you do not deploy depends on your API, its shape is frozen for that consumer. A mobile app on people's phones cannot be updated by you. A partner's integration is on their release schedule. A scheduled job somewhere in the business was written by someone who has left.

Versioning is how you change the contract anyway: the old shape keeps working for callers who have not moved, while a new shape is available for those who have. The first decision is not which versioning scheme to use. It is telling the difference between a change that needs a version and one that does not.

Additive changes can ship to existing callers. Breaking changes cannot. The line is less obvious than it sounds, and several entries below routinely get filed on the wrong side.

 BreakingAdditive
Response fieldsRemoving or renaming a field. Every caller reading it failsAdding a field. Callers that ignore unknown fields are unaffected
Request fieldsAdding a required field. Every existing request becomes invalidAdding an optional field with a documented default
ValidationTightening a rule, such as reducing a maximum length. Requests that worked yesterday now failLoosening a rule, so more requests are accepted than before
TypesChanging a number to a string, or a nullable field to requiredAccepting a wider range of values in the same type
Status codesReturning 404 where an empty result used to give 200Adding a specific code for a case that previously produced 500
EnumsRemoving a value, or changing what an existing value meansAdding a value — additive on your side, and still a risk for a client that rejects anything unknown
PathsRenaming or moving an existing pathAdding a new path alongside the existing ones

Three places the version can live. All three work; they trade off differently:

In the URL path
/api/v1/employees. The most visible option: easy to route, easy to cache separately, easy to try in a browser, and obvious in a log. The objection is theoretical — the version is not really a property of the resource — and it is the most widely used approach in practice.
In a header
A custom header, or a media type such as application/vnd.example.v2+json in Accept. Keeps one address per resource, which is the tidier model. Costs discoverability: a caller cannot try v2 by editing a URL, and forgetting the header silently gets them the default.
In the query string
?api-version=2. Simple to add to an existing API and simple to lose, because a URL copied without its query string still works and quietly returns a different version.
No versioning at all
Defensible only when you deploy every consumer yourself, together. It is a real position for an internal API behind one application, and it stops being true the day a second consumer appears.
Path versioning with route groups, sharing what has not changed
C#
var v1 = app.MapGroup("/api/v1").WithTags("v1");
var v2 = app.MapGroup("/api/v2").WithTags("v2");

// Unchanged between versions: one handler, mapped in both
v1.MapGet("/departments", DepartmentEndpoints.List);
v2.MapGet("/departments", DepartmentEndpoints.List);

// Changed in v2: fullName became a structured name object
v1.MapGet("/employees/{id:int}", EmployeeEndpoints.GetV1);
v2.MapGet("/employees/{id:int}", EmployeeEndpoints.GetV2);

// New in v2 only
v2.MapGet("/employees/{id:int}/leave-balance", EmployeeEndpoints.GetLeaveBalance);

// In EmployeeEndpoints: v1 keeps its shape by mapping from the same source
public static async Task<IResult> GetV1(int id, EmployeeService employees)
{
    var employee = await employees.FindAsync(id);
    return employee is null
        ? Results.NotFound()
        : Results.Ok(new EmployeeResponseV1(employee.Id, employee.FullName));
}
  • Route groups give you the version prefix in one place, so no individual endpoint contains the string v1.
  • Endpoints that did not change are mapped twice from the same handler. Duplicating the code instead is how two versions drift into two behaviours nobody intended.
  • The endpoints that did change have one handler each. That is the honest cost of a version: a second code path, kept working, for as long as the old version is supported.
  • Both versions read from the same service and the same entity. What differs is the response type — which is only possible because the contract was a DTO rather than the entity.
  • For anything larger than this, the Asp.Versioning packages handle versioned routing, defaults and reporting supported versions in response headers. Route groups are enough to start, and they make the mechanism visible while you are learning it.

Choose the scheme before you need it, not during the release that needs it. Retrofitting a version onto an API whose callers all use unversioned paths means either breaking them or keeping the unversioned paths forever as an implicit v1. The second is what usually happens, and it is a permanent oddity in the contract for the sake of a decision that would have taken an hour.

Plan the retirement at the same time. A version with no end date is a version you maintain indefinitely, so publish what supporting an old version means: how long it stays, how you will announce deprecation, and how a caller can tell. There is a standard Sunset response header for stating the retirement date, and a Deprecation header in common use alongside it, and the announcement still needs to reach people rather than only machines.

Then keep the number of versions small. Two is manageable. Four means every bug is fixed up to four times, every security review covers four code paths, and nobody is certain which behaviour is current.

Summary

  • A consumer you do not deploy freezes your contract for that consumer
  • Additive changes can ship; removing, renaming, tightening or retyping cannot
  • The version can live in the path, a header or the query string, and the path is the most visible
  • Decide the scheme before the first breaking change, and publish how old versions are retired
  • Every extra version multiplies tests, documentation and bug fixes, so prefer an additive route when one exists

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Breaking or additive?

Classify each change, and for any breaking one, describe an additive alternative if one exists.

1. Add an optional middleName to the employee response. 2. Rename startDate to employmentStartDate. 3. Make departmentId required on create. 4. Reduce the maximum length of fullName from 200 to 100. 5. Add a new status value Suspended to the employment status enum. 6. Return 404 instead of an empty object when an employee does not exist.

Show solution

1 is additive. Callers that ignore unknown fields are unaffected.

2 is breaking, and there is a clean additive alternative: send both fields for a period, document startDate as deprecated, and remove it in the next version once callers have moved.

3 is breaking — every existing create request omits it. The additive route is to keep it optional with a documented default, and require it only in a new version.

4 is breaking, and the one most often shipped by accident because it is a one-character change to an attribute. Existing callers sending 150 characters start receiving 400s.

5 is additive from your side, with the caveat from the table: a client that rejects unknown enum values will break. Knowing whether your consumers do that is part of the decision.

6 is breaking, because a caller written against the empty object handles a successful response and will now take a failure path it may not have.

Think about it

Retrofitting a version

An API has been live for two years with unversioned paths such as /api/employees. A genuinely breaking change is now needed.

Describe the options, including what happens to the existing paths, and say which you would choose.

Show solution

Option one: introduce /api/v2/... for the changed endpoints and leave the unversioned paths as an implicit v1. Nothing breaks, and you carry a contract where one version has a prefix and the other does not.

Option two: introduce both /api/v1/... and /api/v2/..., keep the unversioned paths working as aliases of v1, and ask callers to move to the explicit prefix over time. More work, and it ends with a consistent scheme.

Option three: version by header, so existing URLs stay exactly as they are and a caller opts into v2 with a header. This avoids new paths entirely and depends on callers remembering the header, so the default has to be v1 forever.

Option two is the choice worth defending, because it ends the inconsistency rather than encoding it permanently — and because the cost of the aliases is small compared with living with two naming conventions for as long as the API exists. The general lesson is that all three options are more expensive than deciding the scheme before the first release.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Which of these is a breaking change?
Why is it worth choosing a versioning scheme before the first breaking change?

Saved in this browser only.