W07 Learning Activity: API Versioning
Overview
- Purpose: Learn how an API can change without unexpectedly breaking the applications that already use it.
- Task: Study compatible and breaking changes, compare common versioning strategies, and complete the check-your-understanding questions.
When you build an API, other code begins to depend on its behavior. An ecommerce API might be used by a store website, a mobile app, a warehouse system, and a business partner. Each client expects certain URLs, request fields, response fields, status codes, and error messages.
You will still need to improve the API over time. The challenge is to add better behavior without suddenly breaking every existing client. API versioning gives you a planned way to introduce breaking changes while an older contract remains available.
Version the public contract, not every deployment
A bug fix or internal code cleanup does not usually need a new API version. A new version is normally needed when public behavior changes in a way that an existing client cannot safely ignore.
The API Contract
An API contract is the behavior a client has been told it can depend on. The contract includes more than the JSON response. It includes the URL, HTTP method, request body, query parameters, headers, authentication rules, status codes, response body, error format, and the meaning of the data.
Suppose version 1 of an ecommerce API returns this product:
{
"id": "665f87b125b0d40012345678",
"name": "Wireless Keyboard",
"price": 49.99,
"category": "electronics",
"inStock": true
}
An online store might run product.price.toFixed(2) to display the price. If the API renames price to unitPrice, that client receives undefined and may fail. The new name could be clearer, but the change still broke the promise made by version 1.
Backward-Compatible Changes
A backward-compatible change allows an existing client to continue working. These changes are often additive: they add an option without removing or changing behavior that clients already use.
- Add a new optional response field.
- Add a new endpoint.
- Add an optional query parameter with the old behavior as its default.
- Add an optional request field.
- Improve performance or fix an implementation bug without changing the contract.
- Improve the documentation for behavior that already exists.
For example, adding inventoryCount is usually safe. A new client can display the quantity, while an older client can ignore the field.
{
"id": "665f87b125b0d40012345678",
"name": "Wireless Keyboard",
"price": 49.99,
"category": "electronics",
"inStock": true,
"inventoryCount": 32
}
Even an additive change deserves thought. A client may reject unknown fields even though flexible JSON clients normally ignore them. Adding a new possible value to a status field can also surprise a client that only knows the old values. Good documentation and tests reduce these risks.
Breaking Changes
A breaking change can make an existing client fail or behave incorrectly. Common examples include:
- Renaming, removing, or changing the meaning or type of a field.
- Changing a response from an array to an object, or changing its nesting.
- Moving an endpoint or changing its HTTP method.
- Making a new request field required.
- Changing authentication or authorization requirements.
- Changing status codes or error formats that clients use in their decisions.
- Changing pagination, filtering, or sorting behavior.
Suppose version 2 stores money as an integer number of cents and adds a currency. It also groups inventory information and wraps the result in data. This may be a better design, but it is not compatible with the version 1 contract.
{
"data": {
"id": "665f87b125b0d40012345678",
"name": "Wireless Keyboard",
"pricing": {
"amountInCents": 4999,
"currency": "USD"
},
"inventory": {
"available": true,
"quantity": 32
}
},
"meta": {
"apiVersion": "2"
}
}
Version 1 and version 2 can run at the same time. Old clients keep reading price, while new clients use pricing.amountInCents. Versioning does not make the change free, but it gives clients time to migrate.
Common Versioning Strategies
URL Path Versioning
GET /api/v1/products
GET /api/v2/products
The version is visible in every URL. This approach is easy to try in Swagger, a browser, or a REST client, and it maps naturally to Express routers. Its main weakness is that careless teams may duplicate an entire application for each version.
Header Versioning
GET /api/products
Accept: application/vnd.store.v2+json
The URL stays stable, and the client selects the representation through a header. However, the requested version is harder to see and easier to forget during manual testing.
Query Parameter Versioning
GET /api/products?version=2
This is easy to test, but the version can look like an optional search filter. It can also make the URL harder to read when the endpoint already accepts search, sorting, and paging parameters.
Host Versioning
https://v1.api.example.com/products
https://v2.api.example.com/products
This creates strong separation, but it requires more DNS and deployment setup. It is usually more work than a small application needs.
Recommended Approach
URL path versioning is a good fit at this point because it is explicit and easy to document, route, and test. The application can mount a router for each major version.
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Each version router then mounts the resources that belong to that version.
import { Router } from 'express';
import productsV1Router from './products.v1.js';
import ordersV1Router from './orders.v1.js';
const router = Router();
router.use('/products', productsV1Router);
router.use('/orders', ordersV1Router);
export default router;
A request for /api/v1/products reaches the version 1 product router. A request for /api/v2/products reaches the version 2 product router. Both may still use the same MongoDB collections.
Share Internal Logic Without Sharing the Contract
Copying every model, query, and business rule into a new folder makes fixes difficult. On the other hand, one controller that contains many version checks becomes difficult to understand. A useful middle ground is to share data access and business rules, while each version controls how requests and responses are shaped.
async function getProductById(id) {
return Product.findById(id).lean();
}
function toV1Product(product) {
return {
id: product._id,
name: product.name,
price: product.priceInCents / 100,
inStock: product.inventoryCount > 0
};
}
function toV2Product(product) {
return {
data: {
id: product._id,
name: product.name,
pricing: {
amountInCents: product.priceInCents,
currency: product.currency
},
inventory: {
available: product.inventoryCount > 0,
quantity: product.inventoryCount
}
},
meta: { apiVersion: '2' }
};
}
Both controllers can call getProductById. The mapping functions protect the response promised by each version. The same idea also works for write operations. Version 1 might accept "price": 49.99, while version 2 accepts a pricing object. Each controller converts its request into the same internal product format before saving it.
Document and Test Every Supported Version
Your OpenAPI documentation should make each supported contract clear. Do not document only version 2 while version 1 is still available. Include request schemas, response schemas, error behavior, authentication, and examples for each version.
Automated tests should also protect both contracts. A version 2 feature is not complete if it accidentally changes version 1.
it('returns the version 1 price field', async () => {
const response = await request(app).get(`/api/v1/products/${productId}`);
expect(response.status).toBe(200);
expect(response.body.price).toBe(49.99);
});
it('returns the version 2 pricing object', async () => {
const response = await request(app).get(`/api/v2/products/${productId}`);
expect(response.status).toBe(200);
expect(response.body.data.pricing).toEqual({
amountInCents: 4999,
currency: 'USD'
});
});
Deprecation and Removal
A new version does not mean the old version should disappear immediately. First announce that the old version is deprecated: it still works, but clients should begin moving away from it. A sunset date tells clients when it is expected to stop working.
Give client developers a migration guide that explains changed URLs, fields, status codes, and examples. You can also send HTTP Deprecation and Sunset response headers. These headers are communication tools; they do not automatically migrate a client.
Do not remove a version silently
Before removal, identify clients that still use the version, provide a clear replacement, announce an exact schedule, and allow enough time to update and test client applications.
Activity Instructions
Check your understanding
Answer each question before expanding the example answer.
-
The product API adds an optional
inventoryCountfield to every response. Is this normally compatible or breaking? Why?Click to expand the example answer
It is normally backward-compatible. Existing clients can keep using the fields they already understand and ignore the new field. It should still be documented and tested.
-
The API renames
pricetounitPrice. The value is still a number. Does this require a new version?Click to expand the example answer
Yes, it is a breaking change. Existing clients ask for
price, so keeping the same value type does not protect them from the renamed field. -
A developer improves the speed of a MongoDB query, but requests and responses do not change. Should the API move from version 1 to version 2?
Click to expand the example answer
No. API versions describe public contracts, not every internal release. The improved query can be deployed as a normal change.
-
Why is URL path versioning a useful choice for this course?
Click to expand the example answer
The version is easy to see, route in Express, document in Swagger, and test with Supertest or a REST client.
-
What code should usually be shared between versions, and what code may need to remain version-specific?
Click to expand the example answer
Database access and business rules can often be shared. Request parsing, validation for that contract, and response mapping may need to remain version-specific.
-
After version 2 is released, why should version 1 remain available for a period of time?
Click to expand the example answer
Existing websites, mobile apps, and partner systems may still depend on version 1. Keeping it available gives those clients time to follow the migration guide and test their updates before the sunset date.
Submission
When you have completed the learning activities for this week, return to Canvas and complete the associated quiz or checkpoint there.
Other Links:
- Return to: Week Overview | Course Home