How to make your API backward compatible (Web, REST, HTTP, etc)
Table of Contents
Summary
Backward compatible API change should satisfy two constraints:
- New server version must be able to read existing clients requests
- Old clients must be able to read responses of the new server version
Intro
It’s a universally accepted standard nowadays to evolve APIs in a way as not to break existing clients. This holds for most APIs, whether JSON over HTTP or gRPC, standard-based (JSON Schema, OpenAPI) or freestyle, public or internal.
The IT field usually refers to such non-breaking changes as “backward compatible” evolution. An API is backwards compatible if a client written against one version of that API will continue to work the same way against future versions of the API. All versions, you might ask? We’ll answer this later.
So what changes exactly are breaking and non-breaking? To answer this we first need to understand different compatibility types.
Compatibility types
We can define 3 main compatibility types.
Backward
Consumers using the new version can process data produced with the old version. Consumer of version (V+1) understands the producer of version (V).
Analogy: when you buy a shiny new TV with HDMI 10.0 you want to be able to connect it to your old streaming device supporting HDMI 9.0, in addition to HDMI 10.0 out devices. HDMI 10.0 is thus backward compatible with HDMI 9.0.
HDMI 10.0 was designed in a way so that clients (TVs) would be able to connect to old 9.0 producers (streaming devices).
Forward
Data produced with a new version can be processed by consumers using the old version. Consumer of version (V) understands the producer of version (V+1).
Analogy: you want your new HDMI 10.0 streaming device to be able to connect to your old HDMI 9.0 TV. This in addition to 10.0 => 10.0 connection.
That might mean that HDMI 9.0 (consumer-side, TVs) was designed with forward thinking, allowing room for future evolution (e.g., new attributes being ignored when not recognized). Thus, when 10.0 was created, it was possible to make it forward-compatible with 9.0.
Full
This is a sum of backward and forward compatibility. Consumer (V+1) understands producer (V); consumer (V) understands producer (V+1).
HDMI 10.0 is fully compatible with HDMI 9.0 iff you can connect HDMI 10.0 output (producer) to HDMI 9.0 TV (consumer) and HDMI 9.0 output to HDMI 10.0 TV.
Note, most of the time we read about backward compatibility. E.g., HDMI 2.1 is backward-compatible. In fact, it’s rather fully compatible. Otherwise, it would be impossible to connect HDMI 2.1 output devices (say, GPU) to old HDMI 2.0 monitors. This is a simplification though, as I suspect the protocol is chosen during protocol negotiation and newer devices would switch to older protocol communication.
Alternative naming
In Avro SchemaValidator terms, backward compatible change is a can-read change. Readers of version (V) can read messages of (V-1) writers.
Forward compatible change is a can-be-read change. Writer (V) can be read by consumer (V-1).
We’ll use this additional intuition later.
API evolution
Compatibility gets more interesting when the communication is not one-way. Most API protocols are two-directional. Usually there’s a request schema (explicit or implicit) and response schema. When we talk about API client in this context, we mean that it both writes and reads.
If we want to introduce a change in the API that is not breaking for existing clients, we can split this problem into two parts: request and response.
To reduce the compatibility problem to the previously discussed formal solution, let’s separate consumer/producer functions, as illustrated below.
Thus, by decomposing a usual request/response two-way communication into, surprise, request and response one-way channels, we can say that API server is a client (consumer) as well as server (producer).
If we pin the API client version, the original task is to evolve server without breaking clients:
- Request change should be backward-compatible
- Response change should be forward compatible
The new server can-read old requests and new responses can-be-read by old clients.
How exactly it is achieved depends on the protocol. Often, backward-compatible changes are:
- Fields deletion
- Adding of optional fields
Forward-compatible changes are often:
- Adding fields
- Delete optional fields
The exact changes allowed depend on the protocol. They might be different for Avro and Protobuf (both grammar-based schema languages); for JSON Schema and OpenAPI Specification (both mixed grammar-based and rule-based language). For schemaless protocol they would be custom depending on the implicit schemas coded in clients and the server.
JSON data format evolution
If JSON API clients are implemented defensively, the backward and forward compatible changes are usually as below.
Backward compatible:
- Widening a numerical type (e.g. integer to number)
- Adding a field with a default value
- Adding an optional field
- Adding a value to an enum string
- Removing a field
Forward compatible:
- Narrowing a numerical type (e.g. number to integer)
- Adding a new required field
- Removing a value from an enum string
- Adding a value to an enum string iff clients have logic to ignore unknown values
Protocol semantics evolution
Protocol semantics should be considered along with the schema. E.g., if a new server version stops accepting a particular value of a parameter (valid though according to schema), this change is not backward-compatible.
In theory such changes can be formally encoded as a protocol schema change. For the example above, the parameter might have enumerated type. In this case, only adding new possible values would be a backward-compatible change for the request.
In practice, it’s usually impossible to encode all constraints and assumptions into a schema. Thus, the observed behavior of the server should remain unchanged for old clients in order to make a backward-compatible change.
Transitive compatibility
Depending on schema language, the fact that schema V is backward (to choose one type) compatible with V-1 and V-1 is compatible with V-2, doesn’t always mean that V is backward compatible with V-2.
Here’s an example for Avro. The consecutive backward compatible changes for this example are:
- V-2: {}
- V-1: {f1: Int (default: 1)}
- V: {f1: Int}
(V) is backward compatible with (V-1) and (V-1) is backward compatible with (V-2). However, (V) is not backward compatible with (V-2).
Transitive compatibility means that schema (V) is checked against all previous versions (V-1, V-2, etc.).
One-way APIs
For APIs that are either response only (say, webhook or other callback type) or request only, the above holds but reduces to only one-way compatibility, depending on a communication direction.
Conclusion
If we decompose a communication between client and API server into two channels, we can see that a backward compatible API change means:
- New server version must be able to read existing clients requests
- Old clients must be able to read responses of a new server version
Having an explicit schema can simplify the guarantee that the change is non-breaking. However, even using a schema-full protocol with defined compatibility rules doesn’t give full guarantee, since not everything can be coded in the schema.