As developers, we should comply with semantic versioning.
We also know, while we continue to develop for our projects, we may need to break APIs to realize our new user requirements.
From a semantic versioning point of view, every time we create a breaking change, we need to increase the MAJOR number by one.
Unfortunately, to fast major increases is not well seen (raises worries about the stability of the project). But also, previous major versions are expected to be maintained (at least when it comes to bugs or security updates.
In this “how to”, we will discuss how we deal with breaking changes.
When to take care of breaking-changes?
Semantic versioning still stipulated that only starting release 1.0.0 (first ever major), APIs are to be considered “stable”. Until then, breaking changes are allowed (even if the user is not happy with it…)
A project should really think of releasing 1.0.0 when:
- It reaches a certain level of maturity
- It reaches a certain amount of users
What is a breaking change?
It feels obvious but let take a look at the following cases:
|Initial code||New code||Breaking change|
|def my_function(a: int, b: int) -> int||def my_function(a: int, b: int, c: int) -> int||yes|
|def my_function(a: int, b: int) -> int||def my_function(a: int, b: int, c: int = 0) -> int||no|
|def my_function(a: int, b: int) -> int||def my_function(a: int, b: int) -> float||yes|
|def my_function(a: int, b: int) -> int||def my_function(a: int, b: int) -> int | float||maybe?|
|def my_function(a: int, b: int) -> int||def my_function(a: float, b: int) -> int||yes|
|def my_function(a: int, b: int) -> int||def my_function(a: int | float, b: int) -> int||no|
|-||def my_function(a: int, b: int) -> int||no|
|def my_function(a: int, b: int) -> int||def my_function_1(a: int, b: int) -> int||yes|
Principle to work for
If we extend our existing functionalities while following the open-closed principle, there should not be any breaking changes visible to our users.
But as you all know, it is not that simple. Ensuring backward compatibilities is directly impacting maintainability in the following ways:
- code we do not delete needs to be maintained (unittests and doc included)
- legacy APIs may include switch cases in new APIs
- legacy APIs may include dependencies to classes or modules we do not need
Different use-cases that introduce breaking changes
|Use-case||User expectations||Size of the complexity|
|Refinement of a functionality||stable APIs||medium|
|Refactoring activities of a functionality||stable APIs||medium|
|Incubation of a new functionality||unstable APIs||big|
How to deal with breaking changes?
- Create an incubation folder in the repo
- The incubation should reflect the same skeleton as the main repository structure
- Copy there the module that will create a breaking change or create there the module that will contain the new functionalities
- Implement it, refactor it
- In the old location, inherit from it and reimplement the APIs to not break the module
- Do not forget the warning
- Users can access the new functionalities with from ebplugins.incubation.emb_aux the APIs with breaking changes
- Do not forget to update:
- The documentation
- Is it somehow possible in the API doc to have only one module shown?
- This way we could see what is the API that is deprecated but also the new API that replaces it
- The information about how to migrate to the new API
- The old module or function with a deprecation warning
- In the module that will contain the breaking change
- Implement the new unittests
- Do not touch the old one (to ensure that the functionality still behave as expected)
- Remark: if the unittest was badly written, it can be updated
- Update the examples to use the new API
- The documentation
When will the breaking changes occur?
When an upgrade to a MAJOR version will occur. There are two possibilities it to happen:
- If the number of breaking changes is reaching a certain amount, a MAJOR release may be needed to cleanup everything
- If a year is reached and it makes sense to move to the next MAJOR, it should be done
How to communicate breaking changes?
To reduce the worries of the different customers, the breaking change should be categorised and also communicate this way:
|Size||Small - Under one hour change Medium - Would take around one day of work Large - Would take several days of work|
|Impact||Which user-team would be impacted by this change (estimation)|
If conventional commits is used, I would recommend to extend it to:
feat(componentA)!: [small][userA, userB] changes something here
What will be done during a MAJOR upgrade?
- Replace / move all eligible modules to "stable APIs" folder
- Cleanup all residues (tests, docs, ...)
- Put a warning in the incubation to tell users to remove the "incubation" keyword in their files
How to deal with recursive breaking changes?
- Let says I do a breaking change and later on I do one again on the same API that we already broke?
- It is ok because the module is still as incubation defined which mean its API is subject to change.
Models to deal with breaking changes already exist (e.g. k8s deprecation policy), why proposing a new one?
- Yes, but they still leave open on how the developers should deal with their breaking changes.