Mastering API Versioning: Strategies for Seamless Frontend-Backend Communication in Mobile Apps
Abdelmoujib Megzari14 min read
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
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",
}),
});
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
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",
}),
});
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",
}),
});
}
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
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
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",
}),
});
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",
}),
});
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.
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.
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.