DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

API Versioning Strategies: Pragmatic Approaches

API versioning is a topic in the software world where the question of "how should I do it?" is more critical than "when should I do it?". Whether designing mobile operator screens for an ERP system in a manufacturing firm or developing integrations for a bank's internal platforms, I've always faced the reality that APIs will change over time. Managing these changes directly impacts both the workload of the development team and the compatibility of integrated third-party systems.

As APIs evolve, our primary goal is to ensure that older clients continue to function, and that new features are introduced without breaking existing functionality. In this post, I will discuss the different versioning strategies I've encountered and their practical implications, along with my experiences. For me, it's always important that the chosen approach aligns with the project's dynamics and the team's capabilities.

Why is API Versioning a Necessity?

When we launch an API, we must remember that it's a "contract." This contract covers the expected input format, the output structure, and error codes of the API. If we change this contract, all clients using this API must also adapt to this change. This is where versioning comes into play to manage this adaptation process.

For example, let's consider adding a new field named sku instead of productCode to the main product list API in a production ERP. If we directly reflect this change to the live API, all older mobile applications or integrated business partners expecting productCode will immediately start encountering errors. This can lead to production line stoppages, disrupted shipments, or broken billing processes. To avoid such situations, being able to support different versions of the API simultaneously is key to a smooth transition. In my experience, such changes usually stem from a transformation in the organizational flow or a new business need. When I say software architecture is often organizational flow, not just software, this is exactly what I mean.

ℹ️ Organizational Impact

API changes are not just a technical matter; they are often a reflection of business units, customer expectations, and the organizational structure. When choosing a versioning strategy, it's critical to consider how business units will adapt to this process, not just the technical team.

URI Versioning: The Most Common, The Most Debated Approach

URI (Uniform Resource Identifier) versioning is the method of including the API version directly in the URL path; for example, /api/v1/products or /api/v2/users. This method is quite popular due to its simplicity and direct testability in a browser. It's considered one of the approaches closest to RESTful principles because it treats each version as a separate resource.

I also used this method when developing the first APIs for a backend of one of my side projects. It was quite easy to configure Nginx reverse proxy settings to direct requests starting with /v1/ to a specific upstream server and those starting with /v2/ to another. The following Nginx configuration simply illustrates this:

server {
    listen 80;
    server_name api.example.com;

    location /v1/ {
        proxy_pass http://backend_v1_cluster;
        # Other proxy settings...
    }

    location /v2/ {
        proxy_pass http://backend_v2_cluster;
        # Other proxy settings...
    }
}
Enter fullscreen mode Exit fullscreen mode

However, this simplicity has some drawbacks. Keeping a separate URL path for each version of the API can lead to bloated URLs over time and a complex routing logic. Especially in cases where only a small field changes, releasing a new major version can lead to unnecessary code duplication or maintenance costs. Furthermore, clients need to update their URLs with every version change, which increases integration costs. I personally experienced the management burden brought by URLs progressing like /v1/products, /v2/products, /v3/products while working on the product catalog API for an e-commerce site. I touched upon this in more detail in the [related: Advanced Routing Techniques with Nginx] post.

Header Versioning: The Address of Flexibility

Header versioning is the method of managing the API version by adding it to HTTP headers; for example, Accept-Version: v1 or X-API-Version: 2. This approach keeps URLs clean and offers the flexibility to serve different versions of a resource via the same URL. It can be quite advantageous, especially when used for internal microservice communication or by advanced clients.

In my experience, we frequently used this method for internal integrations of a large e-commerce site. When exchanging data between backend services, we specified which version we wanted by using the X-API-Version header. This way, the API gateway or load balancer could direct the request to the correct service version. The following curl command demonstrates specifying a version with a header:

curl -H "X-API-Version: 2" https://api.example.com/products/123
Enter fullscreen mode Exit fullscreen mode

The disadvantage of this method is that it's harder to test directly in a browser, as browsers don't send custom HTTP headers by default. Also, direct caching by standard CDNs isn't as straightforward as URI versioning; custom caching rules might be necessary. While developing for the backend of one of my mobile applications, I found that some mobile clients had difficulty managing the Accept header correctly, so using a custom header like X-API-Version was more practical. This was often related to the complexity of client-side SDKs or libraries as well.

Query Parameter Versioning: Quick Fixes and Their Risks

Query parameter versioning is done by adding the API version to the URL's query string; for example, /products?version=1 or /users?api_version=2. This method is often preferred to quickly meet the need for versioning. It can be used especially during the prototyping phase or for simple APIs that won't change very frequently.

In a client project, I resorted to this method when a quick versioning need arose for a small reporting service. Without changing the main API, simply adding a parameter like ?format=v2 to serve the output of a specific report in a different format served our purpose. A simple example with FastAPI:

from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/items/")
async def read_items(version: int = Query(1)):
    if version == 1:
        return {"message": "Items from API v1"}
    elif version == 2:
        return {"message": "Items from API v2 with new features"}
    return {"message": "Unknown API version"}
Enter fullscreen mode Exit fullscreen mode

However, this method has serious drawbacks. Firstly, it pollutes the readability and cleanliness of URLs. Secondly, query parameters are generally used to specify different filters or sorting options of a resource, not different representations of it. Therefore, it can be semantically confusing. Thirdly, it can create problems for caching strategies; different versions of the same URL might require different cache entries, which can reduce the cache hit rate. My personal preference is to avoid this method as much as possible, except for critical internal tools. I usually find myself needing to move to a more robust solution shortly after starting with versioning via a query parameter.

API Deprecation and Sunset Policies: An Inevitable Process

Just as important as launching an API version is disabling it at some point. Evolving technology, changing business requirements, or security vulnerabilities can lead us to a point where older versions can no longer be supported. We need to approach this situation with "deprecation" and "sunset" policies.

When disabling an old integration API in a bank's internal platform, I followed these steps:

  1. Announcement: We informed users (usually other internal teams) when the API would be deprecated and how much time they had to migrate to the new version. This announcement was typically made 6 months in advance.
  2. Warning Headers: We added an HTTP Warning header to requests using the old version. For example: Warning: 299 - "API v1 will be deprecated on 2027-01-01. Please migrate to v2."
  3. Logging and Tracking: We logged clients using the old API version and their usage frequency in detail. This helped us monitor the migration process and identify teams needing support. In a production ERP, I observed that we reduced v1 API calls by 95% within 3 months using journald and Prometheus metrics.
  4. Disabling: On the designated date, we completely shut down the old version and returned a 410 Gone HTTP status for requests.

⚠️ Incorrect Deprecation Management

An incorrectly managed deprecation process can lead to major disruptions in integrated systems. This not only damages the reputation of the technical team but also creates serious issues in business processes. Providing clear communication and sufficient migration time is vital.

The important thing in this process is to be transparent and give users enough time. I recall having to extend the deprecation period when updating the backend APIs for one of my side project's Android spam blocker applications, due to the slowness of Play Store update processes (which can sometimes take 2 weeks).

Zero-Downtime Migrations and Rollback Strategies

API versioning forms the basis for providing uninterrupted service (zero-downtime deployment) and the ability to quickly revert in case of a problem (rollback) when updating our systems. When launching a new API version, it's usually safest to run it in parallel with the existing version for a period.

My favorite strategy in production environments is to combine blue-green deployment or canary deployment with versioning. For example, while we have a blue environment running the v1 API, we set up a green environment running the v2 API. After all tests are completed, we point the load balancer from the blue environment to the green environment. If there's a problem, we can quickly revert to the blue environment. Simulating such parallel operation with Docker Compose is quite easy:

# docker-compose.yml
services:
  api_v1:
    image: my-api:1.0.0
    ports:
      - "8001:80"
    environment:
      API_VERSION: v1

  api_v2:
    image: my-api:2.0.0
    ports:
      - "8002:80"
    environment:
      API_VERSION: v2
Enter fullscreen mode Exit fullscreen mode

With this configuration, I can use a reverse proxy like Nginx to route incoming requests to the api_v1 or api_v2 services based on the X-API-Version header. This provides me with the flexibility to run both v1 and v2 simultaneously and manage traffic routing. Once, I experienced a performance degradation in a new v2 API due to an N+1 query problem in PostgreSQL. I noticed the anomaly in the journald logs and reverting to v1 took me only 2 minutes. Such instant rollbacks are possible thanks to API versioning and proper deployment strategies.

Beyond Versioning: API Evolution and Organizational Flow

While API versioning strategies are technically important, the real issue is why APIs change so often and how these changes are managed. In my observation, having to release major versions frequently is often a sign of a deficiency in API design or organizational processes. Architectural patterns like Event-Sourcing, CQRS, or principles like idempotency can make APIs more evolutionary and less fragile.

For instance, consider an API managing stock movements in a production ERP. Initially, it might only accept productId and quantity, but over time, new fields like batchNumber and warehouseLocation might need to be added. If our API is designed flexibly and we can add new fields optionally, this might not require a v2. However, changes to mandatory fields or transformations in the data types of existing fields inevitably trigger a new version. For me, the important thing is to try to anticipate potential future changes when designing an API and to build a structure that can adapt to these changes with minimal effort.

💡 Foresight in API Design

When designing APIs, keeping mandatory fields to a minimum and leaving fields that might be added in the future as optional reduces the need to release new major versions. Also, applying the idempotency principle to APIs ensures consistent results even if clients send the same request multiple times.

This is not just a technical issue; it's also a reflection of inter-team communication, product management, and business strategies. The evolution of an API is, in fact, the evolution of the business processes that use it. Therefore, I believe it's important to approach API versioning not just as a technical implementation, but as an organizational process. I previously discussed the benefits of such architectures in the [related: My Experiences with Event Sourcing in Enterprise Software] post.

Conclusion: A Pragmatic Approach is Essential

API versioning is an unavoidable part of the software development process. As we've seen, each strategy has its own advantages and disadvantages. My clear position is to choose the most pragmatic solution based on the project's scale, client base, and the team's technical capabilities.

If I'm developing an API that will be used by a broad developer community exposed to the outside world, the simplicity and cacheability of URI versioning are generally a good starting point. However, for internal microservice communications or more controlled environments, header versioning offers me more flexibility. Query parameter versioning is an approach I generally reserve for temporary solutions or very niche needs. What's important is that, whichever method we choose, we clearly define our deprecation and sunset policies and manage these processes transparently. Otherwise, the evolution of our APIs can turn into chaos. In the next post, I will explain how I monitor the performance of these APIs and how I use observability metrics.

Top comments (0)