DEV Community

Cover image for Mastering API Versioning: Strategies for Seamless Frontend-Backend Communication in Mobile Apps
Abdelmoujib MEGZARI for Theodo

Posted on

Mastering API Versioning: Strategies for Seamless Frontend-Backend Communication in Mobile Apps

Effective API versioning is essential for maintaining seamless communication between the frontend and backend. This article explains why API versioning is important and analyses various versioning strategies, offering practical insights for backend and mobile app developers. My goal is to equip you with the knowledge to manage API versions efficiently, by ensuring a practical experience for developers while maintaining a seamless user experience. This article is based on our experience with API versioning in the PassCulture app.

1. Context

In the PassCulture app, as with many applications, we control both the frontend and the backend. However, we do not control the version of the frontend that the user is using. This lack of control over the app version necessitates effective API versioning to ensure compatibility and functionality across different user versions. As the app evolves, the API code may change to accommodate new versions of the app, which may not be compatible with older versions.

For example, consider an API endpoint for user authentication that initially requires a username and password.

@app.route('/auth', methods=['POST'])
def auth():
    username = request.json['username']
    password = request.json['password']
    user = User.query.filter_by(username=username).first()
    if user and user.check_password(password):
        return jsonify({'token': generate_token()}), 200
    return jsonify({'error': 'Invalid credentials'}), 401
Enter fullscreen mode Exit fullscreen mode

The frontend (app V1) would send a request to this endpoint with the following code:

fetch("/auth", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "user",
    password: "password",
  }),
});
Enter fullscreen mode Exit fullscreen mode

Now, suppose we want to add two-factor authentication to the app. We could modify the endpoint to require an additional field, otp, for the one-time password.

@app.route('/auth', methods=['POST'])
def auth():
    username = request.json['username']
    password = request.json['password']
    otp = request.json['otp']
    user = User.query.filter_by(username=username).first()
    if user and user.check_password(password) and user.check_otp(otp):
        return jsonify({'token': generate_token()}), 200
    return jsonify({'error': 'Invalid credentials'}), 401
Enter fullscreen mode Exit fullscreen mode

The frontend (app V2) would need to be updated to include the otp field in the request.

fetch("/auth", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "user",
    password: "password",
    otp: "123456",
  }),
});
Enter fullscreen mode Exit fullscreen mode

However, app V1 would not include the otp field, resulting in an error when users on V1 try to authenticate. To avoid this issue, we need to implement API versioning. This way, both versions of the app can interact with the backend appropriately: app V1 can continue to use the original endpoint without otp, while app V2 can use a new version of the endpoint that requires otp. This ensures compatibility and a seamless user experience across different app versions.

2. Alternatives to API Versioning

Is API versioning the only solution to manage changes in the backend code? Not necessarily. Here are some alternatives to API versioning:

a. Forced App Updates

One way to manage changes in the backend code is to force users to update their app. This approach ensures that all users are on the latest version of the app, which is compatible with the latest backend code. However, forced app updates can be disruptive to users and may not always be feasible, especially if users are on older devices or have limited internet connectivity.

Forced updates are a good solution for critical issues, such as security vulnerabilities, that require immediate action.However, they may not be the best approach for non-critical changes or new features.

b. Feature Flags

Feature flags allow you to control the visibility of new features in the app without requiring a new version. By using feature flags, you can gradually roll out new features to users without forcing them to update the app. However, feature flags can be complex to manage and may not be suitable for all types of changes.

If we take the previous example of adding two-factor authentication, we can write the V2 code as follows:

if (featureFlags.twoFactorAuth) {
  fetch("/auth", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      username: "user",
      password: "password",
      otp: "123456",
    }),
  });
} else {
  fetch("/auth", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      username: "user",
      password: "password",
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Then we would enable the feature flag when all users have updated their app to V2 and we will simultaniously update the backend code to handle the new field.

We will then need to clean the code to remove the old code and the feature flag.

This approach can be useful for simultaneously rolling out new features across different app versions. However, it requires careful management of feature flags and may introduce complexity to the codebase.

c. API Route Backward Compatibility

Another approach is to maintain backward compatibility in the API routes. This means that the backend code is designed to handle requests from older app versions, even if the route has been updated. For example, the backend code can check if the request contains the otp field and handle it accordingly.
Example:

  @app.route('/auth', methods=['POST'])
  def auth():
      username = request.json['username']
      password = request.json['password']
      otp = request.json.get('otp')  # Use get to avoid KeyError
      user = User.query.filter_by(username=username).first()
      if user and user.check_password(password) and (otp is None or user.check_otp(otp)):
        token = generate_token()
        if opt is None:
          limit_access_to_some_features(token)
        return jsonify({'token': token}),200

      return jsonify({'error': 'Invalid credentials'}), 401
Enter fullscreen mode Exit fullscreen mode

This approach can be useful for minor changes. However, it can lead to complex code and potential bugs if not managed properly. It also requires careful testing to ensure that backward compatibility is maintained. It also requires the developer to clean the code once the old version of the app is no longer used.

3. Possible Versioning Solutions

When managing multiple API versions, there are several strategies to consider. Mainly three strategies come to mind:

a. Code level versioning

This solution consists of managing the different versions of the routes in the code.
In this case, the different route versions are just different routes in the code. We declare new routes with the version included in the URL.

Example:

@app.route('/v1/auth', methods=['POST'])
@route.deprecated
def auth():
    pass

@app.route('/v2/auth', methods=['POST'])
def auth_v2():
    pass
Enter fullscreen mode Exit fullscreen mode

In this case, The app specifies the version of the route it wants to use in the URL.

For example the app V1 will use the route /v1/auth as follows:

fetch("/v1/auth", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "user",
    password: "password",
  }),
});
Enter fullscreen mode Exit fullscreen mode

The app V2 will use the route /v2/auth as follows:

fetch("/v2/auth", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    username: "user",
    password: "password",
    otp: "123456",
  }),
});
Enter fullscreen mode Exit fullscreen mode

code versionning schema:<br>
This diagram illustrates the interaction between different versions of a mobile application (App V1 and App V2) with a single API POD. The diagram includes the following elements:<br>
Mobile Applications:<br>
App V1<br>
App V2<br>
API Endpoint:<br>
API served by a single POD<br>
Routes:<br>
App V1 sends requests to v1/route, directed to the API POD.<br>
App V2 sends requests to v2/route, directed to the same API POD.<br>
The diagram visually represents the flow of requests from the mobile applications directly to the single API POD, showing how different app versions interact with the same API endpoint through their respective routes

b. Infrastructure level versioning

This solution consists of deploying multiple API instances in parallel on different pods. Each instance corresponds to a version of the API. The app specifies the version of the API it wants to use in the request. The infrastructure is responsible for routing the request to the correct API instance.

A Pod is a group of one or more containers, with shared storage/network resources, and a specification for how to run the containers. Pods are the smallest deployable units of computing that can be created and managed in Kubernetes. Read more about Kubernetes Pods.
Ifrastucture versionning schema:<br>
This diagram illustrates the interaction between different versions of a mobile application (App V1, App V2, and App V3) with their corresponding API versions via a load balancer. The diagram includes the following elements:<br>
Mobile Applications:<br>
App V1<br>
App V2<br>
App V3<br>
API Endpoints:<br>
API v1 (served by POD 1)<br>
API v2 (served by POD 2)<br>
Routes:<br>
App V1 sends requests to v1/route1 and v1/route2, both directed to API v1.<br>
App V2 sends requests to v1/route1 (to API v1) and v2/route2 (to API v2).<br>
App V3 sends requests to v2/route1 and v2/route2, both directed to API v2.<br>
Load Balancer:<br>
Routes the requests from the mobile applications to the appropriate API endpoints based on the requested route.<br>
The diagram visually represents the flow of requests from the mobile applications through the load balancer to the specific API versions, showing how different app versions interact with different API versions.

In this case, whenever the developer wants to create a new version of a route, he just needs to change the code without thinking about the old version. The old version of the route will still be available as long as the old pod is still running, and the new version will be available as soon as a new pod is deployed with the latest API code.

When a pod is no longer used, it can be deleted. This will delete the old version of the route. Deleting the pod will depend on the oldest version of the app users can still use.

In general, a git branch is created for each version of the API. In case of critical issues, modifying the old code can be done by cherry-picking the fix commit to the versions branch and redeploying it from that branch.

In contrast, to code level versioning, infrastructure level versioning doesn't require the developer to clean the code when the old version is no longer used. Additionaly, infrastructure level versioning ensures that a change in the code will not affect the old versions of the app, thus requiring no quality check on the old versions of the app.

c. Backend For Frontend (BFF) level versioning

This type of versioning is a compromise between the two previous solutions. It consists of splitting the backend into two parts: the main backend and the BFF. The main backend is responsible for the business logic and the data access. The BFF is responsible for calling the main backend and formatting the data in the way that each app version expects. The BFF is versioned, and the app specifies the version of the BFF it wants to use in the request.
BFF versionning schema:<br>
This diagram illustrates the interaction between different versions of a mobile application (App V1, App V2, and App V3) with their corresponding API versions via a load balancer, along with their connection to the main backend. The diagram includes the following elements:<br>
Mobile Applications:<br>
App V1<br>
App V2<br>
App V3<br>
API Endpoints (Backend for Frontend):<br>
API v1 (served by POD 1)<br>
API v2 (served by POD 2)<br>
Main Backend:<br>
Main Backend (served by POD 3)<br>
Routes:<br>
App V1 sends requests to v1/route1 and v1/route2, both directed to API v1.<br>
App V2 sends requests to v1/route1 (to API v1) and v2/route2 (to API v2).<br>
App V3 sends requests to v2/route1 and v2/route2, both directed to API v2.<br>
Load Balancer:<br>
Routes the requests from the mobile applications to the appropriate API endpoints based on the requested route.<br>
Connections to Main Backend:<br>
Both API v1 and API v2 have connections to the Main Backend, allowing them to access core backend services.<br>
The diagram visually represents the flow of requests from the mobile applications through the load balancer to the specific API versions, and subsequently to the main backend. It shows how different app versions interact with different API versions and how these APIs connect to the main backend system.<br>

The main contrast between this solution and the infrastructure level versioning is that it ensures that a change in the business logic will affect all versions of the app. While keeping the advantage of the infrastructure level versioning of not needing to clean the code.
However it requires a good separation of the business logic from the data formatting and the data access.

4. Key Considerations

In order to choose the best solution for API versioning, we consider the following key criteria:

a. Correcting Critical Bugs Across Versions

It’s crucial to be able to check which versions are affected by a bug or a vunerability. It’s also important to be able to fix the bug in all affected versions.

b. Same business logic for all versions

It’s important to ensure that a change in the business logic will affect all versions of the app. Since this can create inconsistances between the different versions of the app. And a use of an old business logic can be a critical security issue.

c. Maniability

It should be easy for the developers to manage the multiple versions:

  • Easy to create, update or delete a version of a route
  • Easy to mark versions as deprecated
  • Easy to update the app's code to use a new version of a route

d. No Version inter-dependency

When managing multiple versions, it's important to be able to make changes to a specific version without affecting others. For example, if there is a bug in version 1.0.0, you should be able to fix it without impacting version 2.0.0. Releasing a new version should not affect existing versions, thereby avoiding the risk of introducing new bugs into old versions. This approach ensures that older versions remain stable and reliable, eliminating the need for unnecessary quality checks on all versions with every release. This strategy should allow for better control over the quality of older versions.

e. Traceability

It should be easy to know which version of a route is being used by which version of the app and vice versa.

This allows for better tracking of the usage of the different versions of the routes, helping on decisions for when to delete or update existing routes.

f. Deployment process

The chosen solution should make the deployment process simple. It should be easy to deploy a new version of a route.

g. Set Up

The effort required to set up the solution should be taken into account. The solution should be easy to set up and maintain.

5. Evaluation of the different solutions

Criteria Infrastructure Level Versioning Code Level Versioning BFF Level Versioning
Correcting Critical Bugs ▲ Capable of addressing fixes; requires identifying and applying fixes individually to each version, followed by re-deployment for each. ✔ Single fix in the codebase with a single re-deployment. ▲ A single fix is possible if it pertains to business logic; otherwise it's similar to infrastructure level versioning.
No Version inter-dependency ✔ Completely isolated instances ✘ Changes in the code might impact multiple versions. ▲ Changes in the main backend (business logic) can influence multiple versions, but a change to a route has no impact on other versions.
Maniability: route creation ✔ Simple route creation. ✔ Simple route creation. ✔ Simple route creation.
Maniability: route update ▲ Update requires some effort: need to modify the concerned route on the different branches associated to each version that we want to update. requires a redployment of the pods associated to the concerned versions. ✔ Simple route update ▲ Update requires some effort when the change is on the BFF level. Easy to update when the change is in the main backend.
Maniability: route deletion ✔ Deletion doesn't require developer intervention: The pod is automatically terminated when no routes for the specific version are in use. However, you cannot delete a single route without deleting all routes for that version(The whole pod is terminated). ▲ Requires developper intervention. Can delete a single route of a specific version. ✔ Doesn't require developper intervention, Can not delete a single route without deleting all routes for that version.
Traceability ✔ Easy to track. ✔ Easy to track. ✔ Easy to track.
Deployment Process ✘ Complex: multiple instances manage ✔ Simple: single deployment process ✘ Complex: multiple instances manage
Set Up ✘ Complex ✔ Simple ✘ The most complex: requires a good separation of the buisness logic, and the features
  • ✔: Solution meets the criterion effectively.
  • ▲: Solution moderately satisfies the criterion.
  • ✘: Solution does not meet the criterion or makes it more complex.

6. Our choice

After evaluating the different versioning strategies, we opted for code-level versioning. Here’s why:

Complexity: We ruled out infrastructure-level versioning due to its complexity in setup and maintenance. Additionally, it was crucial for us that any change in business logic affects all app versions, which infrastructure-level versioning does not guarantee.

Independence: While BFF level versioning provides some degree of version independence, it also introduces significant setup and maintenance challenges. Furthermore, it doesn’t ensure full version independence.

Business Logic Separation: By effectively separating the business logic from data formatting and access, code-level versioning can offer the same benefits as BFF level versioning. In our scenario, complete independence between versions is not critical, thanks to our comprehensive test suite that catches most bugs introduced by changes. Additionally, we are working towards automating end-to-end tests to identify critical bugs in older app versions.

Practicality: Given our context, simplicity is key. Code-level versioning emerged as the most straightforward and practical solution, aligning well with our testing strategies and maintenance capabilities.

Therefore, we settled on code-level versioning as it meets our needs without adding unnecessary complexity.

Conclusion

I have explained the importance of API versioning and presented three solutions—infrastructure-level, BFF-level, and code-level versioning—each with its own pros and cons.

If you are managing an application where backend and frontend evolve independently, versioning is crucial. I suggest starting by determining your priorities and assessing the most important criteria, and I hope this article will help you to select the best versioning strategy for your needs.

Top comments (0)