APIs don’t have to be brittle, fragile things that break every time you touch them.

Imagine you have an API for a simple user service. It lets you get user details and update them.

GET /users/123
{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com"
}
PUT /users/123
{
  "email": "alice.smith@example.com"
}

This works fine. But what if you want to add a new field, say, isActive? If you just add it to the GET response, clients that don’t expect it might break, or worse, they might start ignoring the entire response if their JSON parser is strict.

The common wisdom is to create a whole new API endpoint for the new version, like /v2/users/123. This works, but it’s a lot of duplicated infrastructure. You have to manage two separate sets of routes, controllers, and potentially even business logic, just to serve slightly different data structures. It’s a lot of boilerplate, and it slows down development.

Instead, let’s think about evolving the existing API. We can use versioning strategies that allow us to introduce changes gracefully within the same endpoint. The most common and often least painful is URI versioning.

Here’s how it looks: we embed the version directly into the URL path.

/v1/users/123
/v2/users/123

When a request comes in for /v1/users/123, your API server knows to apply the logic and data structure for version 1. If a request comes in for /v2/users/123, it uses the version 2 logic. This is explicit and easy to understand.

Let’s say you’re using a framework like Express.js in Node.js. You’d set up your routes like this:

// v1 routes
app.get('/v1/users/:id', (req, res) => {
  const userId = req.params.id;
  // Fetch user data for v1
  const userData = getUserV1(userId);
  res.json(userData);
});

app.put('/v1/users/:id', (req, res) => {
  const userId = req.params.id;
  const updateData = req.body;
  // Update user data for v1
  updateUserV1(userId, updateData);
  res.sendStatus(204);
});

// v2 routes
app.get('/v2/users/:id', (req, res) => {
  const userId = req.params.id;
  // Fetch user data for v2, including isActive
  const userData = getUserV2(userId);
  res.json(userData);
});

app.put('/v2/users/:id', (req, res) => {
  const userId = req.params.id;
  const updateData = req.body;
  // Update user data for v2
  updateUserV2(userId, updateData);
  res.sendStatus(204);
});

This is straightforward. Clients explicitly request a version and get the corresponding API behavior. When you want to introduce a breaking change (like renaming a field, removing a field, or changing a data type), you create a new /v2 endpoint. Existing /v1 clients continue to work without modification. New clients can adopt /v2 when they’re ready.

Another common approach is Header versioning. Instead of putting the version in the URL, you use a custom HTTP header, like X-API-Version or Api-Version.

A request for version 1 would look like this:

GET /users/123
Host: api.example.com
X-API-Version: 1

And for version 2:

GET /users/123
Host: api.example.com
X-API-Version: 2

In Express, you’d access this header like so:

app.get('/users/:id', (req, res) => {
  const userId = req.params.id;
  const apiVersion = req.headers['x-api-version']; // Or 'api-version'

  if (apiVersion === '1') {
    const userData = getUserV1(userId);
    res.json(userData);
  } else if (apiVersion === '2') {
    const userData = getUserV2(userId);
    res.json(userData);
  } else {
    // Default to latest or return an error
    const userData = getUserV2(userId); // Assuming v2 is the latest
    res.json(userData);
  }
});

This keeps your URIs cleaner, which some developers prefer. The downside is that it’s less discoverable than URI versioning; you have to know to look for the header.

When you’re transitioning from one version to another, you’ll often want to support both for a period. This means your API server needs to be able to route requests based on the version identifier, whether it’s in the URL or a header, and then execute the correct code path. For URI versioning, this might involve a router that explicitly matches /v1/... and /v2/.... For header versioning, it’s typically a middleware that inspects the header and conditionally executes logic.

The real power comes from abstracting your core business logic away from the API layer. You might have a UserService class that handles all user operations. Then, your API controllers for v1 and v2 simply call methods on this service, potentially with different parameter mappings or data transformation layers.

// Example of abstracting business logic
class UserService {
  getUser(id, version) {
    if (version === '1') {
      return this.getUserV1(id);
    } else {
      return this.getUserV2(id);
    }
  }

  getUserV1(id) {
    // ... logic for v1 ...
    return { id, name: 'Alice', email: 'alice@example.com' };
  }

  getUserV2(id) {
    // ... logic for v2, including isActive ...
    return { id, name: 'Alice', email: 'alice@example.com', isActive: true };
  }
}

// In your v2 controller:
app.get('/v2/users/:id', (req, res) => {
  const userService = new UserService();
  const userData = userService.getUser(req.params.id, '2');
  res.json(userData);
});

This way, the core UserService doesn’t need to know about API versions; it just serves data. The API layer handles the version-specific interpretation and presentation.

One subtle advantage of versioning your API is that it forces you to think about the contract between your API and its consumers. When you have to create a v2 endpoint, you’re explicitly acknowledging that you’re making a change that could break existing clients. This encourages more thoughtful API design and a clear deprecation strategy for older versions.

You might also encounter Accept Header versioning, where the client specifies the desired API version in the Accept header, e.g., Accept: application/vnd.example.v1+json. This is a more RESTful approach but can be more complex to implement and less intuitive for some clients.

The most critical aspect of any API versioning strategy is communication and a clear deprecation policy. When you release v2, you should announce that v1 will eventually be retired. Provide a timeline for deprecation (e.g., "v1 will be supported for 12 months") and give clients ample notice. This allows them to migrate at their own pace.

Once you’ve successfully implemented versioning, the next challenge is managing the lifecycle of older versions, including how to monitor their usage and eventually decommission them.

Want structured learning?

Take the full Monolith course →