Semantic versioning of shared libraries
Semantic versioning allows for better communication of what kind of changes have occurred between releases. Following this framework allows developers to reduce risk when upgrading their library dependency.
If we want to semantically version a shared library, we first need to document actually what does “API / functionality” mean for our library:
- We need to document what is eligible for semantic versioning and can be referenced by userspace, and what is considered private implementation — e.g. all packages in
com.company.libraryare safe to be referenced, except for
com.company.library.implthat can change at any time (this could be avoided by having libraries split into
- Is every interface / class eligible for being extended by the userspace? As an example Eclipse framework uses
@noextendand similar javadoc tags to impose restrictions on platform objects.
- If the library produces or consumes any serialized data (what can be e.g. REST endpoints in embedded HTTP server or telemetry data being sent somewhere over Kafka) we might also want to impose some restrictions on the exposed API or message format, as it allows the users for easier uptake and later decommissioning of code handling the older cases.
In the case of messages, solutions such as protobuf or avro allow for easier specifying forward-and-backwards compatible message formats.
- In case of JVM world, is our library exposing any JMX beans? Is the contract of (some/all) these beans going to follow semver? This is going to have impact on existing tooling/automation that references these beans. This can be considered to be a concrete case of point 3.
- Libraries can also have their own transitive dependencies, what might impact the behaviour of services that use them — making dependency management another area of impact.
Some libraries are not configured only by invoking code, but also read configuration from external sources (e.g. system properties or filesystem files). These properties then can be separated into two groups:
- Public ones — intended to be consumed by wide audience, and as they can be treated as API, they need to be versioned;
- Implementation ones — mostly of the interest to the development team (e.g. temporary feature flags for non-critical sections), that do not need to be tracked as much. These implementation flags are usually expected to be removed after some time — as they allow the library developers to perform some exploration before coming up with final solution, or promoting the property to public one.
Effectively, the library should have a new major release when it is no longer capable of providing the features of an old one, e.g.:
- removing old methods or classes or changing them in an incompatible fashion — e.g. adding required parameters, changing return type);
- changing type hierarchy — as the users could have referenced to instanced by their parent classes);
- adding interfaces to existing classes — as the users could have already created subclasses of these classes with incompatible signatures (e.g. we could add interface’s method
void f()to a class that already has an
- adding new abstract methods to interfaces / classes that are intended to be used by users — as we could have a signature collision again;
- adding incompatible method overloads (e.g.
void f(String)already exists — as invocation
f(null)cannot be then resolved);
- (if library-emitted serialized data is part of contract) changing consumed and accepted messages in an incompatible fashion (e.g. removing fields). Library should also document if any additional fields can appear in emitted messages, as the consumers might then be required to ignore unknown fields (e.g. JsonIgnoreProperties.ignoreUnknown);
- (if JMX is part of contract) all of the the above changes just applied to them, including changing bean names and domains;
- removing configuration properties that are part of API (as they are equivalent to removing a functionality);
- adding new transitive dependencies or major upgrades of existing ones — as their older versions could have been already consumed by the services and upgrade of our library could result an incompatible upgrade of dependency, causing potential issues (e.g. build breakages).
This group of changes should add new capabilities without changing the existing ones — an extra care might be necessary if we just want to expand an existing functionality,
- adding new classes and interfaces;
- adding new methods (as long as they do not collide on overloads with existing ones);
- (if library-emitted serialized data is part of contract) consuming new types of messages (e.g. new message formats or
- (if JMX is part of contract) adding new JMX beans and new methods to them;
- adding new public configuration properties — similar to adding new functionalities;
- adding and removing implementation-level configuration properties — while implementation properties do not need to be versioned, having them bound to a library version allows for easier tracking;
- minor upgrades of transitive dependencies — by definition, a minor does not change existing functionality of dependency, therefore a service that would have this transitive upgrade should not be affected;
- removal of dependencies — this is somewhat discussable, but the impact should be limited if our library is the only requester of transitive dependency — otherwise the service would have specified that the transitive dependency is needed explicitly.
Effectively anything that does not fall into the above sections.
As it has been noted before, some implementation-only changes could theoretically be delivered with patch releases, but tracking the “implementation-feature” together with library might provide additional visibility.
A very strict and pedantic approach to this kind of versioning runs a risk of slowing possible development, due to needs of clients to update to the new major releases — what might be more expensive than uptaking a new minor version.
Due to this, it is important to find a balance about what needs to be versioned, it might be beneficial to reduce the scope of versioning — limit users’ access to particular packages, features at least for some time, until they get to the level of necessary maturity.
After all, versioning is just a tool that should help us develop better products easier, and cause less problems.
What about (top-level) services?
Actually, nothing much! In the end, a “fat” library that self-imposes any restrictions is not too different from a top-level service.