Recently, I’ve gotten to wrap my head around versioning again. If you’ve been following this blog for a while, you might remember that I wrote a post about API versioning in mid-2021. Back then, I focused on versioning API-first developer products. This problem was relatively contained, we used a single versioning scheme to keep systems running for older clients while deploying a breaking change.
For an upcoming product, we’re working on multiple components in a larger system consisting of
- a primary API
- a web application using the API
- desktop apps running on multiple platforms using the API and communicating with
- IDE extensions running on the same machine as the desktop app
We’re still very early stage, so we do not want to maintain multiple versions indefinitely. Instead, we want users to update to the newest version as soon as possible. This is incredibly pragmatic and will not be the best fit for companies in later stages, so take everything with a grain of salt.
In general, there are multiple strategies to versioning, offering different levels of user choice: We may decide to allow users to update the desktop application and IDE extensions whenever they feel like it, in this case, we couldn’t just deploy a breaking change to the API. We could also be more strict, requiring users to upgrade as soon as a new version is published.
As usual, there’s no catch-all solution. We need to make a tradeoff between flexibility and complexity (mostly cost as a function of time).
Versioning and Continuous Integration
In the world of web applications and APIs, changes are usually released without user choice, as often as multiple times per day. In contrast, desktop applications and packages are versioned, offering users a clear upgrade path and the choice to update when they want.
In recent years, continuous integration has enabled fast-moving teams to ship changes as quickly as possible, reacting to product usage and feedback. For our first stage, we’re striving to reach the same delivery experience for our desktop application and IDE extensions.
Shouldn’t we support multiple versions in the API?
For the API, we have a choice to offer multiple versions or a single version to API consumers. Supporting multiple versions is great for backward compatibility (i.e. serving older clients that have not been updated yet). Unfortunately, every additional version adds a maintenance burden. Not only that, by design it becomes harder to introduce breaking changes that naturally affect older versions (like removing a feature or changing its core functionality).
While this sounds like a rare occurrence, we’re still figuring out the core product. This means everything is subject to change. We cannot risk any slowdown from multiple versions, not least because this generates absolutely zero value for the company early on.
Version numbers vs. date-based versions
Some teams stick to semantic versioning religiously, introducing breaking changes only in major versions. We’ve also seen increasing adoption of date-based versioning, where version numbers reflect the rough deployment time.
In addition to connecting releases to time, the date-based approach is helpful to align components on a single version again, even when individual components received more updates in the past. In contrast, if you’re simply incrementing a version number for every change, it will be hard to get to the same version if some components evolve faster than others. This property will come in handy in the future when we distribute improvements as they are made while batching up bigger changes into scheduled product releases with an identical version.
When should we create a new version?
While every deployment has a unique identifier, version numbers are sometimes decoupled from builds. Products like Stripe only introduce a new version when a breaking change is added. This makes it easier to check if a client update is necessary or not.
I believe having multiple identifiers makes it harder to reason about the system and dependencies than it should be, so reducing the variation is probably good. Every act of distributing a new version to users should increase some version number, somewhere.
How flexible should we be with mismatching versions?
This goes in the same direction as supporting multiple versions in the API. Some products, especially in the enterprise segment, allow users to update when they’re prepared to do so. This transfers control from the developers to the users, which can be good in principle but always causes additional effort to maintain multiple versions and consider legacy users.
We’d like to avoid this burden early on. If we push a new version, users should update to it. We’ll surely be more lenient later on, but to ship fast, we need to minimize the discrepancy between the latest version and the slowest user to update their version.
This does not mean, that we want to pester users with constant update-now-type dialogs, of course. We’ll find a cadence that both allows us to ship continuously and is acceptable to our users.
Can we release components independently for strict versioning?
If the system does not have a way to determine if a client is compatible with a server beyond simple version equality, the default should be to ask users to update to the newest version. This also means that we always need to build and distribute all components for every release. Otherwise, users may end up with an older client for a newer API without an available update.
Unfortunately, by implementing strict versioning, some valid cases are ruled out as well. If we just want to release a minor fix to the desktop client, we have no way of telling it to use the “older” API. Instead, we’d have to downgrade to the broken desktop version or create a new API deployment with the same version number. This is far from the most elegant solution, but it is the tradeoff we choose to work with.
Don’t strict versions lead to timing issues?
If clients only work with servers running the same version and vice-versa, using an old client on a newly released API will fail. If the new desktop application is not ready for download yet, users will be unable to use the product. This makes it important to schedule new releases such that all time-intensive build and distribution steps are completed before components like the API are made public.
If we supported multiple versions in the API, we could handle multiple desktop versions. This may add flexibility in one part of the system, however, this approach does not work at the interface between the desktop application and IDE extensions. There can only be one instance of the desktop application, and a breaking change will always require an immediate update on the other side. We could argue it’s more consistent to be strict on all ends.
How could we version components properly?
I acknowledge that the decision to be incredibly strict about not supporting older versions for a millisecond isn’t great from many perspectives. Let’s imagine we had the time and capital to invest actual brain power into introducing proper versioning.
Consider the relation between the API and desktop application. The desktop application expects the API to work the same way it did when it was released. The API may introduce non-breaking changes, but breaking changes require upgrading the desktop client to a new version that supports the changed API structure. This means the desktop application should work on API versions starting from the point of release to an unknown future release introducing breaking changes.
The difficulty is that we do not know when such a breaking change occurs. If we still have older API clients sending requests to a newer API deployment that introduced a breaking change, we may experience undefined behavior.
We need to cover the following cases:
- API version = Desktop version: All good
- API version > Desktop version: API potentially introduced breaking change
- API version < Desktop version: Desktop potentially requires newer API
One way to solve this is by creating a strict policy, and increasing the version identifier exclusively for breaking changes. This way, whenever the API version mismatches the desktop requirement, we could prompt users to update the application.
If we want to increase version numbers more flexibly, we need to define version constraints for each component. The desktop app only supports APIs as old or newer than itself. The API may not be able to handle requests from older clients, so it supports clients as old or newer than itself. The same goes for the remaining system components. Each component requires up-to-date counterparts, while components on the receiving end need to deny any requests from older, unsupported clients.
Imagine we introduced a breaking change to the API. From that point on, the API would only allow desktop clients released at the same time or afterward. The new desktop client, supporting the breaking change, would not work on older APIs. We might add some flexibility by supporting both versions in the API to give users more time to update.
In short,
- every interface consumer needs to define minimum versions of the server (desktop needs current API or newer)
- every interface server needs to define minimum versions of consumers (current API needs current desktop or newer)
- this reciprocal requirement ensures lower and upper version boundaries (an older desktop will be denied access by an API that introduced breaking changes and requires a newer desktop)
- the reciprocal requirement enables upper boundary flexibility: The desktop application must not define ahead of time how many future API versions it supports. Components can be released individually.
- for backward compatibility, the server should support older versions for some time until most clients have been updated