Practical vs. Strict Semantic Versioning
SemVer is a means, not an end.
In my last post I went into detail on maintaining API, binary, and wire compatibility for open source projects and why that’s a nececssary ingredient for building professional-grade open source, the type that can be successfully morphed into a sustainable project should the authors pursue that outcome.
In this post I want to cover a subtle issue that you will inevitably run into as soon as you start having to worry about breaking changes for your users: semantic versioning (SemVer) and how strictly you should follow it.
My opinion, with which technical purists will likely find fault, is that strict SemVer is hilariously impractical and projects that follow it blindly actually subvert their own goal of building trust in their project. Read on.
Strict SemVer
Under strict SemVer the following is true - emphasis mine:
Major version X (
X.y.z | X > 0
) MUST be incremented if any backwards incompatible changes are introduced to the public API
And
Software using Semantic Versioning MUST declare a public API. This API could be declared in the code itself or exist strictly in documentation. However it is done, it SHOULD be precise and comprehensive.
The public API doesn’t necessarily mean 100% of strict public members - only what is clearly communicated to be for public consumption downstream.
Under strict SemVer, if I take any public API member in Akka.NET and make any breaking change to it I’m supposed to bump the major version number of Akka.NET.
That’s a ridiculous idea and projects that adopt it are no more professional than projects that don’t set and meet compatibility expectations at all.
Here’s why: what users expect when it comes to major version numbers is something much more substantial than an obscure API change.
When users decide to upgrade from one major version to another they expect:
- That there will be some breaking changes;
- That those changes will be worth the tradeoffs; and
- The authors of the software won’t have released a new major version change without condition 2 being the raison d’être for condition 1.
Under strict SemVer we have a rigid process that disregards the users’ expectations and effectively paints the maintainers of the OSS into a corner from which they can’t escape without:
- Adopting strict SemVer and devaluing major releases or
- Without having to roll any and all breaking changes into major releases, which necessitates coupling all sorts of changes together regardless of the impact.
Developers often focus on the wrong thing when it comes to SemVer, which is the technical purity of each release - the right thing is always setting the user’s expectations correctly.
I’ve maintained Akka.NET since November 2013 and over the course of that time I’ve personally overseen hundreds of software releases, 90ish to the core Akka.NET framework itself. The rate at which users upgrade to new versions of dependencies varies tremendously - and that is a consideration that OSS maintainers should factor into their decision making about releases and compatibility.
Why do users delay upgrading between major versions of OSS libraries and tools? Because they’re using that OSS in critical applications there is uncertainty involved in performing those upgrades. The reward has to be worth the risk.
This is why projects that:
- Frequently make breaking changes to their APIs or wire format;
- Follow strict SemVer; and
- Thus create lots of new releases all the time
End up fragmenting their userbase across many different versions of the software, which isn’t a healthy place to be. It spreads your maintenance work out across many different concurrent versions of the software, frustrates users, and makes your software seem riskier to adopt.
SemVer gets abused into creating a permission structure where breaking changes are always acceptable so long as there’s a major version bump - and this is just as bad as not having any explicit versioning strategy at all.
Practical SemVer
You have to balance end-user risk with reward, hence the need for practical semantic versioning.
Photo by Shamia Casiano from Pexels
Practical SemVer is essentially:
- Major or minor version bumps are necessary when intentional breaking changes target feature areas that are actively consumed by users, extensions, downstream dependencies, and more. Those breaking changes must be cost-justified in order to be included and must come with a public-facing migration plan for users.
- Version bumps are not necessary when introducing a breaking change if it targets a feature area that is either explicitly marked as not meant for public consumption (those exist and sometimes have to be
public
orprotected
rather thaninternal
orprivate
) or it’s not an area of the software that has high user-impact (i.e. it’s part of an experimental or deprecated public API; the method is functionally broken as-is; or it’s simply not frequently used.)
This approach:
- Avoids sending unnecessary “high risk” upgrade signals to end-users for mostly trivial breaking changes that aren’t likely to have an impact in the first place;
- Forces the project to avoid making breaking changes where possible;
- Forces the project to cost-justify breaking changes (i.e. it should be done primarily for user-motivated, rather than maintainer-motivated, benefit typically;) and
- Allows the project to have more flexibility in what sort of changes can be made immediately versus ones that have to be deferred into a major / minor version release.
The biggest downside to practical SemVer is that it’s subjective - the maintainers have to decide what the impact of any breaking change is. Strict SemVer removes that element of personal judgment and replaces it with hard and fast rules.
I’ve found practical SemVer quite straightforward in practice over many years - users appreciate it and it has given us the flexibility to fix some poorly conceived designs, introduce + change experimental ones, and do so responsively as part of the ongoing maintenance of our projects.