Building an SDK is a great way to share your ideas with the Dart community, and to enable developers to access products that empowers their application. However, it comes with a few challenges.
When it reaches a certain age (which means a few months in the fast Dart ecosystem) a SDK will start to be exposed to breaking changes in its dependencies. This is something that is bound to happen as every pub package out there relies at least on the Dart SDK (which is not immune) to breaking changes. To deal with this issue, this article explores three strategies you may follow to ensure a minimal impact on your users, but also on the implementation cost and maintainability of your SDK.
Dart pub uses semantic versioning. It means among other things that all packages must be versioned using (at least) the X.Y.Z format, where X is called the major version, Y the minor version and Z the patch version. Semantic versioning also states that each new version that increases the minor or patch version number should be backward compatible with all the other versions.
It means that when using the caret syntax in your pubspec.yaml (like collection: ^1.15.0) pub will accept any version between 1.15.0 and 2.0.0 (not included) because semantic versioning guarantees that any of these versions will work with the API defined in the version 1.15.0. This obviously relies on the fact that the collection (or any other package you would want to use) follows the semantic versioning principles, so do not hesitate to pay attention that your dependencies follow them and to warn their maintainers when they don’t. Note that introducing breaking changes without respecting this convention will result in bugs for your users.
With this in mind, let’s imagine that you’ve built the 1.0.0 version of the super_example
package. This package is built on top of another package, called super_dependency
. Your package starts to have quite an impact on the community as you see more and more issues and articles about it. Everything is good under the sun, until one day, super_dependency
releases version 4.0.0 with a bunch of breaking changes.
Your users start to update their super_dependency
version and see that your package creates conflicts with it, so they have two possible choice at this point:
super_dependency
to be able to continue using your package. It means they won’t get the new API and optimizations of the super_dependency
package, so they are mad.super_dependency
and drop your super_example
package. It means they won’t be able to use your great work anymore, so they are mad.This is not ideal for you because either way your package creates unintended frustration, whereas it should just be a useful addition to your user’s applications. You need to release at least a new version of super_example
to handle this breaking change.
Ideally, you would want all these combinations to work:
super_dependency: ^4.0.0
and super_example: ^new_version
. It’s the “everyone updates” scenario where your user updates everything.super_dependency: ^3.0.0
and super_example: ^new_version
. It’s a scenario where your user has the choice to update it’s super_dependency
or not. It’s exceptionally relevant when your SDK is associated to a paid service, and you don’t want to create an entry barrier of updates for your potential customers. Not only that, but it also guarantees that even the users that don’t update their super_dependency
version still gets your new features.Let’s look at 3 strategies you could follow to handle this breaking dependency.
In certain cases, we can use some hacks to fix the breaking change while still supporting the old versions of the dependency. Here is a practical example: Let’s say the super_dependency
have an abstract Dependency class as below:
Let’s say you implement this class in the 1.0.0 version of your super_example
like this :
Version 4.0.0 of super_dependency
is out, and now the Dependency class looks like this:
Notice that the param is now typed with int.
Now let’s say you update your implementation like this:
This will ensure that the new version of the super_example
package is compatible with the version 4.0.0 of the super_dependency
package. However, it won’t be backwards compatible with the ^3.0.0 versions because in these versions, the Dependency class method must be overridden with a String param.
It seems impossible to have a backwards compatible way of doing this using a legit way.
But we can hack our way through by doing this :
Here, we removed the type annotation. It’s possible because Dart is smart enough to understand that the type of param is the same as the parent method. That way we can support all versions.
This approach is the only one that gives complete freedom to your users, but there is a catch. By following this strategy, you would introduce one or multiple code smells in your SDK, and hit its maintainability. Each hack you add will increase the difficulty to keep the library up, especially in the future. In the example above, you must make sure that no devs will add the int annotation in front of param, at any point in the close future. This rule will not “natively” be caught by your IDE, as your local dev environment will probably depend on the last version of super_dependency
which will indicate that the type of param is int, so you have to test and document this piece of code cautiously
Finally, this method is not always possible. A good example is what if the Dependency class is simply renamed? You would then be implementing a class that doesn’t exist in the previous versions of super_dependency
, or one that does not exist in the newer versions.
Here is a quote from the Dart documentation:
"It’s important to actively manage your dependencies and ensure that your packages use the freshest versions possible."
So the idea here is to bump the super_dependency
package version to 4.0.0, correct your implementation and release a new version of the super_example
package that does not support the prior versions of super_dependency
. This is what 99% of pub packages do, including Flutter if you consider Dart as its main dependency. When a new Flutter version is released, you can be sure that it will use the last stable Dart version. This is by far the best strategy to ensure that everyone is trying to go forward with using the latest versions of every library.
This is also the easiest solution for you, because you don’t have to maintain more than the usual. After the update, you can completely forget about the older versions of the super_dependency
package and if anybody complains about it, they are in the wrong for not trying to update their dependencies, right? Well, it’s not really about who is in the wrong or not, but about offering the best experience to your users. Choosing this path still means that if someone doesn’t want to update its version of super_dependency
for any reason, then they won’t get your updates neither. So this is actually not the ideal situation for your users because you force them to update the super_dependency
package to get your new features.
As mentioned in the introduction, it can become problematic if your users are actually customers of a service your SDK provides. In this scenario, bumping the dependency version would mean two things. First, they would be forced to update by a service they pay for, which is not good publicity, and second, it would mean reducing the range of potential new customers your service might attract. It will be easy to convince your current users to update, as they are probably happy with your package. However, it might create situations where you would speak to a potential new client and need to say: “You want our service? You need to update first.”. This could close the door for some deals in the coming months, as a lot of devs tend to wait before updating their dependency.
An alternative to this is to wait a few months before bumping the version. The advantage is you keep the ease of maintainability, and you give more time to your users to consider an update. But this also means that they cannot update before you do, which is not ideal.
This strategy is a last resort if the second strategy does not work for you. The idea is to double your releases during a short period of time after the release of version 4.0.0 of super_dependency
. The way it would work is you would split the super_example
package into two branches. On the first one, you would release the 2.0.0 version of super_example
which depends on super_dependency
4.0.0 and higher. Then a few days or weeks later, when you need to release a new feature, you release it twice: one as the 1.1.0 version which still relies on version 3.0.0 of super_dependency
and one as the 2.1.0 version which depends on version 4.0.0 and higher.
In this scenario you would have to maintain two versions of super_example
but most of the extra work could be automated using release scripts that creates the release branch for you. Still, this solution is not suited for every project. Because of the heavy maintenance cost it adds, it requires a very strict behavior concerning tests and documentation, from each member of your team. If you are your own team, then this solution might not be suited for you as it adds a lot work on your plate. That is why most of pub packages uses the second strategy instead of this one, among the fact that this strategy does not motivate your users to update, but rather give them the choice. It’s the best solution for them, because they would have complete freedom on the versions they want to use.
Unfortunately, as you probably understood, it’s the worst solution for you as it greatly increases the maintenance cost of your SDK. The main issue with this strategy is that it works well with one breaking change, but as soon as there’s another one, it doubles. If you rely on another dependency which also breaks, then you have 4 versions to manage. If you only do that for a few months though, it would become very unlikely that two unrelated dependencies break at the same time. But if it were to happen, your super_example
package would become impossible to maintain. Supporting two versions of the same package can already lead to much more issues even with automation of the release process, but four would be an actual nightmare.
Each of these strategies have their cons, so none of them can be recommended with eyes closed. With what we saw, my advice would be to first consider the second strategy, bumping your dependency version, with a short delay for the update (around 3 months). The Dart ecosystem has been designed this way for a reason : it changes constantly and very fast. Making sure that each package uses the latest versions of its dependencies ensure that the ecosystem doesn’t get stuck into legacy code, which is a pain for every developer that has to handle this code. This is a commonly known principal in the Flutter/Dart community, as most of Flutter updates depends on the latest Dart SDKs so that the framework always enables the developers to use the latest features of the language.
However, if this strategy is impossible, then I would consider using the third strategy and never the first. The maintenance cost is high but the risk is not as high as adding code smells in your codebase.
Finally, some other strategies might exist, a good candidate I didn’t talk about being dropping the conflicting dependency. In any case you should now have some ideas on how to make this super_example
package thrive along its super_dependency
! If you want to improve your code quality, make sure to check out our article on how to create custom lints and add them to your project.
Dart changelogs: https://github.com/dart-lang/sdk/blob/main/CHANGELOG.md
Package versioning (Dart documentation): https://dart.dev/tools/pub/versioning
Semantic versioning: https://semver.org/spec/v2.0.0-rc.1.html