Today in micro service’s world, we do frequent deployments. Make minimal changes and then push to prod. However, sometimes we still want to push out a feature gradually to reduce impacts if anything goes wrong.
Scenario I: We are changing implementations internally in a service. Such as instead of calling one api, the service is now calling another third party api.
Or before it was calling a provider’s SOAP service, now that external service got migrated to rest service. So our service is changing to call the rest service instead. For this scenario, there is no contract change. No endpoint change for our api. Client does not need to make any changes. But how do we gradually roll it out to reduce risk? Things could go wrong. Though we do a lot of testing, there may still be something not covered.
So how do we roll it out gradually to reduce risk?
Solution a: Istio – weight-based routing by percentage to point to different deployment/service
Pipeline needs to be tweaked to deploy a different version of codes and have a deployment name.
The following is an example virtual service that sends 75% of traffic to v1 and 25% traffic to v2.
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: reviews
spec:
hosts:
- reviews
http:
- route:
- destination:
host: reviews
subset: v1
weight: 75
- destination:
host: reviews
subset: v2
weight: 25
Solution b: Codes – put some conditions at top level (controller/handler) to route traffic to the old or new implementation.
A simple random number can be used to decide where we want the traffic to be routed so that randomly 20% of the traffic goes to the new implementation while the rest still goes to the old implementation.
II. Scenario: api contract has small changes.
a. add new fields to response – this does not break the client. It simply makes new fields available to client and client can decide when to consume those new fields
b. add new fields to request – as long as these fields are not marked as required and not null, client requests can still reach the api logic. It may or may not require coordination with client. It is a case by case situation.
c. delete or remove fields in request or response – this will break the clients. coordinations are needed.
d. rename a field break the client
e. Change a field’s type will break a client. Such as before a field is a boolean. Then it was change to String type.
III. Scenario: Huge structure change in api contract
This could be a totally refactored request/response structure and fields. Let’s say it this way. Do whatever you can at the beginning to have a formal contract such as in a swagger yaml file. Scrutinize the request and response. Mercilessly refactor it if needed before it goes to prod or before more than one clients are using it. Any time after then making api contract change is painful and it requires a lot of coordination work with the client. Unless your api only has one client and your team also work on the client.
IV. Strategies to deal with API contract changes that could break the clients
Just saying. If the contract change is so big due to certain business requirement, then why not just make this as a new api, instead of updating the old api? That would be a simple approach. In most cases, this type of changes are very infrequent. Likely it happens several years in between.
An api could have many clients, let it be either different apps, or other services that need to call this api. When a new version of the api is introduced which has contract changes that break the clients, the clients will still be using the old version of contract until they are ready to switch to the new version. Until the last client is moved to use the new version of contract, or when it reaches the deadline the api owner gave to end the support of the old version, then we need to continue to support the older version of contract structure. This is called backward compatibility for the api.
Then how do we support backward compatibility?
a. API Versioning. This is the oldest and still essential way to deal with breaking changes.
This is commonly done by different end point, having version number as part of endpoint. An example of this type of url path is like: /api/myawesomeapi/v2. There are other ways to provide this version, such as in a query parameters. Or in the headers.
This type of versioning backward compatibilities support can be done by the following:
a.1. Having separate deployments for each version
Let’s talk about in Kubernetes’ term. Make the new version as a separate deployment so it has its own pods, while the older versions’s deployment still running in the same cluster. The different end point will route them to the corresponding versions.
Then client select to point to which one. Service will simultaneously support both versions. Having 2 or more deployments running in the cluster.
Different end points routing to the correct service can be done through either Istio or akamai.
a.2. Use codes to do the routing
Since it is still the same api, there are many commonalities at core though different versions. Then at higher level of codes, mostly the controller, send then to their individual handlers. An adapter pattern could be used to eventually both goes to the same core of the api.
b. Using codes to support more than 1 version without endpoint changes
This technique is only good when the breaking change is small, such as 1-2 field change or a small structural change.
For a small structure change in the request, support both
old request
{
"firstName": "John",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium"
}
new request format
{
"users": [
{
"firstName": "John",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium"
},
{
"firstName": "Marry",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium"
}
]
}
For backward compatibility reason, we can add the new structure to the existing request without removing the old fields. So it will be able to handle both type of request. Only remove the old fields when all clients are using the new structure type of requests.
{
"firstName": "John",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium",
"users": [
{
"firstName": "John",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium"
},
{
"firstName": "Marry",
"lastName": "Smith",
"dateOfBirth": "1990-01-16",
"membership": "Premium"
}
]
}
Adapter pattern can still be used here. Taking the request of the old version and convert that also to a list of users. Then both versions can go the same core logic.
By doing it this way, without having to provide a different end point, we can support both versions. Whether some clients send in request in the old contract format, or other clients send in request in the new contract format, both are supported and handled by upper layer codes.
This only works when the changes to the contract are very limited and easy to keep track. Once all clients moved on to use the new contract, then the fields for the old contract can be cleaned up and codes be cleaned up to only support the new contract.
V. Final Words
Either strategy to handle the contract changes, it will be a hassle to support more than one version of contract. For public apis or apis that directly being called by mobile apps or other type of apps, there is no choice because it is hard to control when the clients will move on to use the newer version. Having separate deployments will have the codes isolated and provides more stability to the api.
For a company’s own service apis, since it is other teams calling an api, or in some case there is only one client, then it is much easier to collaborate with those clients and try to help them to move to use the new version of contract. For those cases, in another word, they are similar to trunk based development. It is better to just have a single version. Only another version is temporarily needed, make it short lived so the api can go back to a single version of contract.